@cosyte/mllp 0.0.1 → 0.0.3

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @cosyte/mllp
2
2
 
3
- > Send and receive HL7 v2 over a production-grade MLLP connection in a few lines — with framing, ACK correlation, auto-reconnect, and backpressure handled for you.
3
+ > Send and receive HL7 v2 over a production-grade MLLP connection in a few lines, with framing, ACK correlation, auto-reconnect, and backpressure handled for you.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@cosyte/mllp.svg)](https://www.npmjs.com/package/@cosyte/mllp)
6
6
  [![CI](https://img.shields.io/github/actions/workflow/status/cosyte/mllp/ci.yml?branch=main&label=CI)](https://github.com/cosyte/mllp/actions/workflows/ci.yml)
@@ -16,7 +16,7 @@ A developer-focused MLLP (Minimal Lower Layer Protocol) client and server for No
16
16
  ## Quickstart
17
17
 
18
18
  ```bash
19
- # pnpm (recommended) — also works with: npm install @cosyte/mllp | yarn add @cosyte/mllp
19
+ # pnpm (recommended), also works with: npm install @cosyte/mllp | yarn add @cosyte/mllp
20
20
  pnpm add @cosyte/mllp
21
21
  ```
22
22
 
@@ -34,20 +34,20 @@ const client = await createStarterClient({ host: "localhost", port: 2575 });
34
34
  const ack = await client.send(Buffer.from("MSH|^~\\&|..."));
35
35
  ```
36
36
 
37
- The payload API is **Buffer-first** everywhere — HL7 v2 messages are raw bytes with caller-managed charset decoding.
37
+ The payload API is **Buffer-first** everywhere. HL7 v2 messages are raw bytes with caller-managed charset decoding.
38
38
 
39
39
  ## MLLPS / TLS
40
40
 
41
- TLS is built on `node:tls` — no bundled TLS, no extra dependency. Certificate verification is **on
41
+ TLS is built on `node:tls`: no bundled TLS, no extra dependency. Certificate verification is **on
42
42
  by default**; the server binds `127.0.0.1` by default and requires an explicit opt-in
43
43
  (`allowWildcardBind: true`) to bind all interfaces.
44
44
 
45
45
  ```ts
46
- // Server — plain TLS
46
+ // Server: plain TLS
47
47
  const server = createServer({ tls: { cert: certPem, key: keyPem } });
48
48
  await server.listen(2575, "127.0.0.1");
49
49
 
50
- // Server — mutual TLS (ATNA ITI-19)
50
+ // Server: mutual TLS (ATNA ITI-19)
51
51
  const server = createServer({
52
52
  tls: { cert: certPem, key: keyPem, ca: clientCaPem, clientAuth: "MUST" },
53
53
  });
@@ -57,7 +57,7 @@ const server = createServer({
57
57
  // Client
58
58
  const client = createClient({ host: "mllp.example.com", port: 2575, tls: { ca: caPem } });
59
59
 
60
- // Client — mutual TLS
60
+ // Client: mutual TLS
61
61
  const client = createClient({
62
62
  host: "mllp.example.com",
63
63
  port: 2575,
@@ -68,9 +68,9 @@ const client = createClient({
68
68
  See the **MLLPS / TLS** doc for the `ClientAuth` table, the TLS 1.2 floor (IHE ATNA ITI-19), typed
69
69
  failure modes (`tls-verify` vs `tls-handshake`), and bind-safety details.
70
70
 
71
- ## Fail-safe ACKs — the commit contract
71
+ ## Fail-safe ACKs: the commit contract
72
72
 
73
- A positive acknowledgement (`AA`) tells the sender *"you may forget this message — I have it."* So a
73
+ A positive acknowledgement (`AA`) tells the sender *"you may forget this message. I have it."* So a
74
74
  receiver must never send one before the message is durably handled, or the message is silently lost.
75
75
  `@cosyte/mllp` makes that structural: pair `autoAck: 'AA'` with an `onMessage` handler and the server
76
76
  **awaits your handler (the durable-commit step) and only then ACKs**.
@@ -86,28 +86,33 @@ const server = createServer({
86
86
 
87
87
  Handler resolves ⇒ `AA`. Handler throws ⇒ `AE` (or `AR` via `MllpAckError`). **A positive ACK cannot
88
88
  precede a successful commit.** `autoAck: 'AA'` *without* a handler is documented as a
89
- transport-accept — "received and framed", not "processed".
89
+ transport-accept: "received and framed", not "processed".
90
90
 
91
91
  ## What's in the box
92
92
 
93
93
  - **Client + server** with strict MLLP framing (`VT + payload + FS + CR`), ACK correlation, auto-reconnect with backoff, and backpressure.
94
- - **The commit contract** — a positive ACK can never precede a durable commit; an unparseable inbound can never yield a positive ACK.
95
- - **Explicit 6-state connection machine** (`CONNECTING | CONNECTED | DRAINING | RECONNECTING | DISCONNECTED | CLOSED`) with `stateChange` events — never socket flags.
94
+ - **The commit contract**: a positive ACK can never precede a durable commit; an unparseable inbound can never yield a positive ACK.
95
+ - **Explicit 6-state connection machine** (`CONNECTING | CONNECTED | DRAINING | RECONNECTING | DISCONNECTED | CLOSED`) with `stateChange` events, never socket flags.
96
96
  - **Lenient decoder, strict encoder** (Postel's Law) with **11 stable warning codes** carrying byte offsets. Tolerance is opt-in per flag; the server ships tolerant defaults.
97
- - **TLS (MLLPS)** — verification on by default, mutual TLS (`clientAuth: 'NONE' | 'WANT' | 'MUST'`), a TLS 1.2 floor per IHE ATNA ITI-19, and bind-safety guardrails (`127.0.0.1` default, wildcard binds require opt-in). `AbortSignal` on every awaitable and `Symbol.asyncDispose` on every closeable.
98
- - **PHI-safe diagnostics** — no error, warning, event payload, or stats object ever echoes a *run* of message content; a framing error carries at most the single byte at the structural violation.
99
- - **In-memory transport** (`@cosyte/mllp/testing`) — a deterministic, socket-free test double for fast, reliable tests.
100
- - **`@cosyte/hl7` as an optional peer** — the `@cosyte/mllp/ack-from-hl7` subpath builds ACKs from parsed messages when it's installed.
97
+ - **TLS (MLLPS)**: verification on by default, mutual TLS (`clientAuth: 'NONE' | 'WANT' | 'MUST'`), a TLS 1.2 floor per IHE ATNA ITI-19, and bind-safety guardrails (`127.0.0.1` default, wildcard binds require opt-in). `AbortSignal` on every awaitable and `Symbol.asyncDispose` on every closeable.
98
+ - **PHI-safe diagnostics**: no error, warning, event payload, or stats object ever echoes a *run* of message content; a framing error carries at most the single byte at the structural violation.
99
+ - **In-memory transport** (`@cosyte/mllp/testing`): a deterministic, socket-free test double for fast, reliable tests.
100
+ - **`@cosyte/hl7` as an optional peer**: the `@cosyte/mllp/ack-from-hl7` subpath builds ACKs from parsed messages when it's installed.
101
101
  - **Zero runtime dependencies.** Node stdlib only.
102
102
 
103
103
  ## What it deliberately does not do
104
104
 
105
- MLLP + ACK is **at-least-once at best** — your application owns idempotency and de-duplication
105
+ MLLP + ACK is **at-least-once at best**. Your application owns idempotency and de-duplication
106
106
  (`MSH-10` + `MSH-7`). This package does not parse HL7 (use `@cosyte/hl7`), does not queue or replay
107
107
  unacked messages, does not decide clinical acceptance, does not speak MLLP **Release 2**, and ships
108
- no PKI. The full list is in the **Known limitations & non-goals** doc — read it before you depend on
108
+ no PKI. The full list is in the **Known limitations & non-goals** doc. Read it before you depend on
109
109
  this.
110
110
 
111
+ ## Trademarks
112
+
113
+ Epic, Cerner, Mirth Connect, NextGen, and Google Cloud Healthcare are trademarks of their respective owners. cosyte is not affiliated with, endorsed by, or
114
+ sponsored by any of them. The names identify the engines this package is tested against, and those it is not. See [TRADEMARKS.md](./TRADEMARKS.md).
115
+
111
116
  ## License
112
117
 
113
118
  MIT
package/TRADEMARKS.md ADDED
@@ -0,0 +1,30 @@
1
+ # Trademarks
2
+
3
+ `@cosyte/mllp` is an independent open-source project. cosyte is **not affiliated with, endorsed by,
4
+ or sponsored by** any company named in this repository or its documentation.
5
+
6
+ ## Why these names appear
7
+
8
+ This package names other systems in two places: to identify the engines its differential test
9
+ harness runs against, and to state plainly which engines it has **not** been verified against.
10
+
11
+ Every reference is **descriptive**: it identifies whose engine was tested against, or whose behaviour is explicitly not covered. Naming a system is the only way to say
12
+ whether a library works with it.
13
+
14
+ ## How these names are used
15
+
16
+ This package has no vendor profile system. The names identify freely available software used for
17
+ interoperability testing, or appear in documentation recording the limits of that testing,
18
+ including an explicit statement that Epic and Cerner are **not** part of the harness.
19
+
20
+ ## Names referenced
21
+
22
+ | Name | Where it appears |
23
+ | ---- | ---------------- |
24
+ | Mirth Connect, NextGen | Named as an engine in the differential test harness. |
25
+ | Google Cloud Healthcare | Named as an engine in the differential test harness (the MLLP adapter). |
26
+ | Epic, Cerner | Named in `docs-content/limitations.md` to record that this package is **not** differentially verified against them. |
27
+
28
+ All product names, logos, and brands are the property of their respective owners. Use of a name here
29
+ does not imply any affiliation with, or endorsement by, its owner. If you own one of these marks and
30
+ would like a reference changed, please open an issue.
@@ -2,7 +2,7 @@
2
2
 
3
3
  var module$1 = require('module');
4
4
 
5
- // node_modules/.pnpm/tsup@8.5.1_postcss@8.5.10_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js
5
+ // node_modules/.pnpm/tsup@8.5.1_postcss@8.5.10_tsx@4.21.0_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js
6
6
  var getImportMetaUrl = () => typeof document === "undefined" ? new URL(`file:${__filename}`).href : document.currentScript && document.currentScript.tagName.toUpperCase() === "SCRIPT" ? document.currentScript.src : new URL("main.js", document.baseURI).href;
7
7
  var importMetaUrl = /* @__PURE__ */ getImportMetaUrl();
8
8
 
@@ -22,13 +22,13 @@ var MllpFramingError = class _MllpFramingError extends Error {
22
22
  /**
23
23
  * Up to 64 bytes copied from around the anomaly.
24
24
  *
25
- * This is a **copied** Buffer — isolated from the source buffer so it remains
25
+ * This is a **copied** Buffer, isolated from the source buffer so it remains
26
26
  * valid after the underlying buffer is reused or overwritten.
27
27
  *
28
- * **PHI contract (MLLP-9):** the decoder only ever populates this with the single
28
+ * **PHI contract:** the decoder only ever populates this with the single
29
29
  * framing-boundary byte that violated the frame structure (whose hex value the
30
- * `message` already discloses) — never a run of payload content bytes. For anomalies
31
- * whose fault is not a specific byte (`MLLP_FRAME_TOO_LARGE` — the accumulated size),
30
+ * `message` already discloses), never a run of payload content bytes. For anomalies
31
+ * whose fault is not a specific byte (`MLLP_FRAME_TOO_LARGE`, the accumulated size),
32
32
  * the snippet is **empty**: a payload slice on a public error field would leak a
33
33
  * field-body slice of clinical content. Callers constructing this directly are
34
34
  * responsible for the same discipline.
@@ -110,9 +110,9 @@ var UNSAFE_FIELD_SEPARATORS = /* @__PURE__ */ new Set([
110
110
  SEGMENT_SEPARATOR_CR,
111
111
  SEGMENT_SEPARATOR_LF,
112
112
  11,
113
- // VT — MLLP start block
113
+ // VT, MLLP start block
114
114
  28
115
- // FS — MLLP end block
115
+ // FS, MLLP end block
116
116
  ]);
117
117
  function isMshSegmentAt(buf, i) {
118
118
  return i + 3 < buf.length && buf[i] === ASCII_M && buf[i + 1] === ASCII_S && buf[i + 2] === ASCII_H && !UNSAFE_FIELD_SEPARATORS.has(buf[i + 3]);
@@ -211,7 +211,7 @@ var MllpPeerMissingError = class _MllpPeerMissingError extends Error {
211
211
  */
212
212
  constructor(cause) {
213
213
  super(
214
- `${PEER_PACKAGE_NAME} is required by ${SUBPATH_NAME} but is not installed. ${PEER_PACKAGE_NAME} is an optional peer dependency of @cosyte/mllp \u2014 install it alongside @cosyte/mllp to use ${SUBPATH_NAME}: \`npm install ${PEER_PACKAGE_NAME}\` (or the equivalent for your package manager).`,
214
+ `${PEER_PACKAGE_NAME} is required by ${SUBPATH_NAME} but is not installed. ${PEER_PACKAGE_NAME} is an optional peer dependency of @cosyte/mllp, install it alongside @cosyte/mllp to use ${SUBPATH_NAME}: \`npm install ${PEER_PACKAGE_NAME}\` (or the equivalent for your package manager).`,
215
215
  { cause }
216
216
  );
217
217
  if (Error.captureStackTrace) {
@@ -252,8 +252,16 @@ function loadHl7Peer(requireFn) {
252
252
  // src/ack-from-hl7/build.ts
253
253
  var MLLP_ACK_INBOUND_UNPARSEABLE = "MLLP_ACK_INBOUND_UNPARSEABLE";
254
254
  var MLLP_ACK_CONTROL_ID_NOT_VERBATIM = "MLLP_ACK_CONTROL_ID_NOT_VERBATIM";
255
+ var MLLP_ACK_CONTROL_ID_UNVERIFIABLE = "MLLP_ACK_CONTROL_ID_UNVERIFIABLE";
255
256
  var BYTES_INBOUND_ENCODING = "latin1";
256
257
  var TEXT_INBOUND_ENCODING = "utf8";
258
+ var SERIALIZABLE_ACK_ENCODINGS = /* @__PURE__ */ new Set([
259
+ "utf8",
260
+ "utf-8",
261
+ "ascii",
262
+ "latin1",
263
+ "binary"
264
+ ]);
257
265
  function assertKnownAckCode(peer, code) {
258
266
  const known = Object.values(peer.ACK_CODES);
259
267
  if (typeof code !== "string" || !known.includes(code)) {
@@ -266,6 +274,12 @@ function resolveEncoding(inbound, options) {
266
274
  if (options.encoding !== void 0) return options.encoding;
267
275
  return Buffer.isBuffer(inbound) ? BYTES_INBOUND_ENCODING : TEXT_INBOUND_ENCODING;
268
276
  }
277
+ function assertSerializableAckEncoding(encoding) {
278
+ if (SERIALIZABLE_ACK_ENCODINGS.has(encoding.toLowerCase())) return;
279
+ throw new TypeError(
280
+ `buildMllpAck: encoding ${JSON.stringify(encoding)} is not a serializable ACK codec. Serializing an ACK to a non-text codec (base64/base64url/hex/utf16le/ucs2) produces a wholesale-garbage frame the receiver cannot parse, on a Buffer inbound it also garbles the inbound decode, so it can never correlate. Use a text codec ("utf8", "ascii", or "latin1"); on a Buffer, "latin1" is byte-verbatim and is the default.`
281
+ );
282
+ }
269
283
  function inboundBytes(inbound, encoding) {
270
284
  if (Buffer.isBuffer(inbound)) return inbound;
271
285
  if (typeof inbound === "string") return Buffer.from(inbound, encoding);
@@ -281,7 +295,23 @@ function verifyVerbatimEcho(ackPayload, inboundRaw) {
281
295
  const echoed = echoedId === null ? "absent" : `${String(echoedId.length)} bytes`;
282
296
  return {
283
297
  code: MLLP_ACK_CONTROL_ID_NOT_VERBATIM,
284
- message: `ACK does not echo the inbound MSH-10 verbatim (HL7 v2.5.1 \xA72.9.2.2): inbound MSH-10 is ${inboundLen} bytes, emitted MSA-2 is ${echoed}, and they differ. The sender keys its in-flight store on the inbound bytes, so it will NOT match this ACK (ACK timeout -> resend -> duplicate message). Field values are withheld \u2014 MSH-10 is inbound payload content and this warning goes to a log; compare your inbound payload against MllpAck.payload to see the bytes.`
298
+ message: `ACK does not echo the inbound MSH-10 verbatim (HL7 v2.5.1 \xA72.9.2.2): inbound MSH-10 is ${inboundLen} bytes, emitted MSA-2 is ${echoed}, and they differ. The sender keys its in-flight store on the inbound bytes, so it will NOT match this ACK (ACK timeout -> resend -> duplicate message). Field values are withheld, MSH-10 is inbound payload content and this warning goes to a log; compare your inbound payload against MllpAck.payload to see the bytes.`
299
+ };
300
+ }
301
+ function verifyTextInboundEcho(controlId, inbound) {
302
+ if (Buffer.isBuffer(inbound)) return null;
303
+ if (controlId === "") return null;
304
+ let hasNonAscii = false;
305
+ for (let i = 0; i < controlId.length; i++) {
306
+ if (controlId.charCodeAt(i) > 127) {
307
+ hasNonAscii = true;
308
+ break;
309
+ }
310
+ }
311
+ if (!hasNonAscii) return null;
312
+ return {
313
+ code: MLLP_ACK_CONTROL_ID_UNVERIFIABLE,
314
+ message: `ACK's byte-verbatim echo of MSH-10 (HL7 v2.5.1 \xA72.9.2.2) cannot be verified: the inbound was decoded text (a string or Hl7Message), not raw bytes, so its wire bytes are gone and the ${String(controlId.length)}-code-unit MSA-2 control ID holds a non-ASCII code unit the decode/encode codec could have altered (a lossy override such as ascii can even mask it into the ASCII byte range). It may not correlate at the sender (ACK timeout -> resend -> duplicate message). Pass the raw payload Buffer to get the byte-level guarantee. Field values are withheld, MSH-10 is inbound payload content and this warning goes to a log.`
285
315
  };
286
316
  }
287
317
  function toMllpAck(params) {
@@ -291,7 +321,13 @@ function toMllpAck(params) {
291
321
  allowDelimiterBytesInPayload: options.allowDelimiterBytesInPayload ?? false
292
322
  });
293
323
  const echoWarning = verifyVerbatimEcho(payload, params.verifyAgainst);
294
- const allWarnings = echoWarning === null ? warnings : [...warnings, echoWarning];
324
+ const echoedControlId = ack.segments("MSA")[0]?.field(2).text ?? "";
325
+ const unverifiableWarning = params.inbound === void 0 ? null : verifyTextInboundEcho(echoedControlId, params.inbound);
326
+ const allWarnings = [
327
+ ...warnings,
328
+ ...echoWarning === null ? [] : [echoWarning],
329
+ ...unverifiableWarning === null ? [] : [unverifiableWarning]
330
+ ];
295
331
  const result = {
296
332
  frame,
297
333
  payload,
@@ -322,11 +358,12 @@ function buildUnparseableFallback(peer, requestedCode, fatalCode, options, encod
322
358
  warnings,
323
359
  options,
324
360
  encoding,
325
- // MSA-2 is INTENTIONALLY empty here — the inbound could not be parsed, so there is no
326
- // control id to echo and we refuse to fabricate one. Running the verbatim check would
327
- // report that deliberate choice as a violation; `MLLP_ACK_INBOUND_UNPARSEABLE` (above)
361
+ // MSA-2 is INTENTIONALLY empty here, the inbound could not be parsed, so there is no
362
+ // control id to echo and we refuse to fabricate one. Running either echo check would
363
+ // report that deliberate choice as a problem; `MLLP_ACK_INBOUND_UNPARSEABLE` (above)
328
364
  // is the accurate warning for this case, and it is already attached.
329
- verifyAgainst: void 0
365
+ verifyAgainst: void 0,
366
+ inbound: void 0
330
367
  });
331
368
  }
332
369
  function resolveInbound(peer, inbound, encoding) {
@@ -343,6 +380,7 @@ function buildMllpAck(inbound, options) {
343
380
  const peer = loadHl7Peer();
344
381
  assertKnownAckCode(peer, options.code);
345
382
  const encoding = resolveEncoding(inbound, options);
383
+ assertSerializableAckEncoding(encoding);
346
384
  let msg;
347
385
  try {
348
386
  msg = resolveInbound(peer, inbound, encoding);
@@ -374,7 +412,8 @@ function buildMllpAck(inbound, options) {
374
412
  warnings,
375
413
  options,
376
414
  encoding,
377
- verifyAgainst: inboundBytes(inbound, encoding)
415
+ verifyAgainst: inboundBytes(inbound, encoding),
416
+ inbound
378
417
  });
379
418
  }
380
419
  function buildAckAA(inbound, options) {
@@ -402,6 +441,7 @@ function detectMode(inbound) {
402
441
  }
403
442
 
404
443
  exports.MLLP_ACK_CONTROL_ID_NOT_VERBATIM = MLLP_ACK_CONTROL_ID_NOT_VERBATIM;
444
+ exports.MLLP_ACK_CONTROL_ID_UNVERIFIABLE = MLLP_ACK_CONTROL_ID_UNVERIFIABLE;
405
445
  exports.MLLP_ACK_INBOUND_UNPARSEABLE = MLLP_ACK_INBOUND_UNPARSEABLE;
406
446
  exports.MllpPeerMissingError = MllpPeerMissingError;
407
447
  exports.buildAckAA = buildAckAA;