@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/README.md CHANGED
@@ -1,136 +1,359 @@
1
1
  <a href="https://cosyte.com">
2
2
  <picture>
3
3
  <source media="(prefers-color-scheme: dark)" srcset="https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png">
4
- <img alt="Cosyte: a plus mark set in two overlapping rounded squares, one solid and one outlined, beside the Cosyte wordmark" src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
4
+ <img alt="The Cosyte logo on its own white ground: the icon beside the word Cosyte." src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
5
5
  </picture>
6
6
  </a>
7
7
 
8
8
  # @cosyte/mllp
9
9
 
10
- > 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.
10
+ > HL7 v2 over MLLP for Node.js, with framing, ACK correlation and reconnects handled for you.
11
11
 
12
12
  [![npm version](https://img.shields.io/npm/v/@cosyte/mllp.svg)](https://www.npmjs.com/package/@cosyte/mllp)
13
13
  [![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)
14
14
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE)
15
15
  [![Node](https://img.shields.io/badge/node-%3E%3D22-brightgreen.svg)](https://nodejs.org)
16
16
 
17
- A developer-focused MLLP (Minimal Lower Layer Protocol) client and server for Node.js and TypeScript. Transport-only sibling to [`@cosyte/hl7`](https://github.com/cosyte/hl7) (the parser): `@cosyte/mllp` moves the bytes, `@cosyte/hl7` reads them.
18
-
19
- **Status: under active development. API not yet stable (`0.0.x`).**
20
-
21
- ---
22
-
23
- ## Quickstart
17
+ Production-grade MLLP client and server for Node.js, transport-only sibling to @cosyte/hl7
18
+
19
+ ## Contents
20
+
21
+ - [Why this exists](#why-this-exists)
22
+ - [Status](#status)
23
+ - [Install](#install)
24
+ - [Usage](#usage)
25
+ - [PHI and safety](#phi-and-safety)
26
+ - [API](#api)
27
+ - [Compatibility](#compatibility)
28
+ - [Contributing](#contributing)
29
+ - [Trademarks](#trademarks)
30
+ - [License](#license)
31
+
32
+ ## Why this exists
33
+
34
+ HL7 v2 interfaces still move over MLLP, and the protocol stops at the frame: `VT + payload + FS + CR`
35
+ and nothing else. Everything that decides whether an interface survives a bad night is left to the
36
+ integrator, so every team writes the same acknowledgement correlator, the same reconnect loop and the
37
+ same backpressure guard, and one of them is subtly wrong. The nearest alternative is a full
38
+ integration engine such as Mirth Connect, which brings a runtime, a database and a deployment story
39
+ you may not want inside a Node.js service. `@cosyte/mllp` is that transport on its own: a library you
40
+ import, with zero runtime dependencies, an in-memory transport so your tests never open a socket, and
41
+ one guarantee it will not trade away, which is that a positive acknowledgement cannot precede a
42
+ durable commit.
43
+
44
+ ## Status
45
+
46
+ **This README describes `0.1.0`.** The public API is settled and safe to depend on: the client, the
47
+ server, the framing codec, the in-memory transport and the optional `ack-from-hl7` bridge are what
48
+ this release commits to. The stable warning codes and security-warning codes are part of that
49
+ surface, so renaming or removing one is a breaking change.
50
+
51
+ Named rather than implied, here is what is still moving:
52
+
53
+ - **Batch acknowledgement is not built.** An HL7 batch envelope (`FHS`/`BHS`, HL7 v2.5.1 §2.10.3) or
54
+ a frame carrying more than one `MSH` yields a warned, non-positive `AE`. That is deliberate, since
55
+ a positive acknowledgement correlated to the first message would tell the sender a whole batch was
56
+ accepted while the rest went unread, but it means the surface that would carry batch support does
57
+ not exist yet.
58
+ - **Verified engine coverage is a growing list, not a complete one.** The differential harness runs
59
+ against freely available engines only. See [Compatibility](#compatibility) for who is in it and who
60
+ is not.
61
+ - **MLLP Release 2 is not spoken**, and no option selects it.
62
+
63
+ ## Install
24
64
 
25
65
  ```bash
26
- # pnpm (recommended), also works with: npm install @cosyte/mllp | yarn add @cosyte/mllp
66
+ # engines.node: >=22.0.0 packageManager: pnpm@10.0.0
67
+ # published dual: ESM (import) and CJS (require), each with its own type declarations
27
68
  pnpm add @cosyte/mllp
69
+
70
+ # optional peer, needed only for the ack-from-hl7 subpath
71
+ pnpm add @cosyte/hl7
28
72
  ```
29
73
 
74
+ ## Usage
75
+
76
+ Start a server, send it a message, and read the acknowledgement it built. Auto-ACK is on by default,
77
+ and the server awaits your handler, which is the durable commit step, before it answers. The payload
78
+ API is **Buffer-first** everywhere: HL7 v2 messages are raw bytes with caller-managed charset decoding.
79
+
30
80
  ```ts
31
- import { createStarterServer } from "@cosyte/mllp";
81
+ import { createStarterClient, createStarterServer } from "@cosyte/mllp";
32
82
 
33
- // Auto-ACK is on by default: the server awaits your handler (the durable-commit
34
- // step) before it answers, so a positive ACK can never precede the commit.
35
- // A throw answers a negative ACK instead.
83
+ const committed: Buffer[] = [];
84
+
85
+ // Port 0: the OS picks a free port. The host defaults to loopback, 127.0.0.1.
36
86
  const server = await createStarterServer({
37
- port: 2575,
87
+ port: 0,
38
88
  onMessage: async (payload) => {
39
- await db.commit(payload);
89
+ committed.push(payload); // your durable commit: a throw here answers AE, never AA
40
90
  },
41
91
  });
42
- ```
43
92
 
44
- ```ts
45
- import { createStarterClient } from "@cosyte/mllp";
93
+ const client = await createStarterClient({ host: "127.0.0.1", port: server.getStats().port ?? 0 });
94
+
95
+ const ack = await client.send(
96
+ Buffer.from(
97
+ "MSH|^~\\&|SENDING_APP|SENDING_FAC|RECEIVING_APP|RECEIVING_FAC|20260101120000||ADT^A01|CTRL0001|P|2.5.1\r",
98
+ ),
99
+ );
100
+
101
+ // Log the shape of the acknowledgement, never its field values.
102
+ const msa = ack
103
+ .toString("utf8")
104
+ .split("\r")
105
+ .find((segment) => segment.startsWith("MSA|"))
106
+ ?.split("|");
107
+ console.log("acknowledgement code:", msa?.[1]);
108
+ console.log("MSA-2 echoes the control id sent:", msa?.[2] === "CTRL0001");
109
+ console.log("messages committed:", committed.length);
110
+
111
+ await client.close();
112
+ await server.close();
113
+ ```
46
114
 
47
- const client = await createStarterClient({ host: "localhost", port: 2575 });
48
- const ack = await client.send(Buffer.from("MSH|^~\\&|..."));
115
+ ```text
116
+ acknowledgement code: AA
117
+ MSA-2 echoes the control id sent: true
118
+ messages committed: 1
49
119
  ```
50
120
 
51
- The payload API is **Buffer-first** everywhere. HL7 v2 messages are raw bytes with caller-managed charset decoding.
121
+ `MSA-2` echoes the control id you sent, byte for byte, which is what lets the client match the answer
122
+ to the message. The acknowledgement's own `MSH-7` and `MSH-10` change on every run.
52
123
 
53
- ## MLLPS / TLS
124
+ ### Framing on its own
54
125
 
55
- TLS is built on `node:tls`: no bundled TLS, no extra dependency. Certificate verification is **on
56
- by default**; the server binds `127.0.0.1` by default and requires an explicit opt-in
57
- (`allowWildcardBind: true`) to bind all interfaces.
126
+ `encodeFrame` and `FrameReader` are exported so you can frame and unframe without a socket. The
127
+ reader is a stateful decoder: feed it whatever TCP hands you, and complete frames fire on `onFrame`.
58
128
 
59
129
  ```ts
60
- // Server: plain TLS
61
- const server = createServer({ tls: { cert: certPem, key: keyPem } });
62
- await server.listen(2575, "127.0.0.1");
130
+ import { encodeFrame, FrameReader } from "@cosyte/mllp";
63
131
 
64
- // Server: mutual TLS (ATNA ITI-19)
65
- const mutualTlsServer = createServer({
66
- tls: { cert: certPem, key: keyPem, ca: clientCaPem, clientAuth: "MUST" },
67
- });
68
- ```
132
+ const payload = Buffer.from(
133
+ "MSH|^~\\&|SENDING_APP|SENDING_FAC|RECEIVING_APP|RECEIVING_FAC|20260101120000||ADT^A01|CTRL0001|P|2.5.1\r",
134
+ );
69
135
 
70
- ```ts
71
- // Client
72
- const client = createClient({ host: "mllp.example.com", port: 2575, tls: { ca: caPem } });
136
+ const frame = encodeFrame(payload);
137
+ console.log(frame.subarray(0, 3).toString("hex"), frame.subarray(-2).toString("hex"));
138
+ console.log("frame", frame.length, "payload", payload.length);
73
139
 
74
- // Client: mutual TLS
75
- const mutualTlsClient = createClient({
76
- host: "mllp.example.com",
77
- port: 2575,
78
- tls: { ca: caPem, cert: clientCertPem, key: clientKeyPem },
140
+ const reader = new FrameReader({
141
+ onFrame: (bytes, byteOffset, warnings) => {
142
+ console.log("onFrame at", byteOffset, bytes.length, "bytes,", warnings.length, "warnings");
143
+ },
79
144
  });
145
+
146
+ reader.push(frame.subarray(0, 10)); // partial chunk: nothing fires
147
+ reader.push(frame.subarray(10)); // completes the frame
148
+ ```
149
+
150
+ ```text
151
+ 0b4d53 1c0d
152
+ frame 105 payload 102
153
+ onFrame at 0 102 bytes, 0 warnings
80
154
  ```
81
155
 
82
- See the **MLLPS / TLS** doc for the `ClientAuth` table, the TLS 1.2 floor (IHE ATNA ITI-19), typed
83
- failure modes (`tls-verify` vs `tls-handshake`), and bind-safety details.
156
+ `0b` is the leading `VT`, `1c0d` the trailing `FS CR`, and the three framing bytes are the whole
157
+ difference between 105 and 102.
84
158
 
85
- ## Fail-safe ACKs: the commit contract
159
+ ### The commit contract
86
160
 
87
- A positive acknowledgement (`AA`) tells the sender *"you may forget this message. I have it."* So a
161
+ A positive acknowledgement (`AA`) tells the sender _"you may forget this message. I have it."_ So a
88
162
  receiver must never send one before the message is durably handled, or the message is silently lost.
89
163
  `@cosyte/mllp` makes that structural: pair `autoAck: 'AA'` with an `onMessage` handler and the server
90
- **awaits your handler (the durable-commit step) and only then ACKs**.
164
+ **awaits your handler and only then acknowledges**.
165
+
166
+ Handler resolves, you get `AA`, **unless the inbound could not carry a correlatable positive
167
+ acknowledgement** (no readable `MSH`, an empty `MSH-10`, a batch or concatenated payload, or trailing
168
+ bytes the framer discarded). In that case the commit still happened, but the acknowledgement is
169
+ downgraded to `AE` and a `nack` event names the reason, because a positive acknowledgement the sender
170
+ cannot match is one it will resend. Handler throws, you get `AE`, or `AR` via `MllpAckError`.
171
+ `autoAck: 'AA'` _without_ a handler is documented as a transport-accept: "received and framed", not
172
+ "processed".
173
+
174
+ ### Transport security (MLLPS)
175
+
176
+ TLS is built on `node:tls`: no bundled TLS, no extra dependency. Certificate verification is **on by
177
+ default**, the server binds `127.0.0.1` by default, and binding all interfaces needs an explicit
178
+ `allowWildcardBind: true`.
91
179
 
92
180
  ```ts
181
+ import { createServer, createClient } from "@cosyte/mllp";
182
+
183
+ // Server: mutual TLS (IHE ATNA ITI-19)
93
184
  const server = createServer({
94
- autoAck: "AA",
95
- onMessage: async (payload) => {
96
- await db.commit(payload); // throw here ⇒ AE (resend may succeed), never AA
97
- },
185
+ tls: { cert: certPem, key: keyPem, ca: clientCaPem, clientAuth: "MUST" },
186
+ });
187
+ await server.listen(2575, "127.0.0.1");
188
+
189
+ // Client: verify the peer, and present a certificate of our own
190
+ const client = createClient({
191
+ host: "mllp.example.com",
192
+ port: 2575,
193
+ tls: { ca: caPem, cert: clientCertPem, key: clientKeyPem },
98
194
  });
99
195
  ```
100
196
 
101
- Handler resolves ⇒ `AA`, **unless the inbound could not carry a correlatable positive ACK** (no
102
- readable `MSH`, an empty `MSH-10`, a batch or concatenated payload, or trailing bytes the framer
103
- discarded). In that case the commit still happened, but the ACK is downgraded to `AE` and a `nack`
104
- event names the reason, because a positive ACK the sender cannot match is one it will resend.
105
- Handler throws ⇒ `AE` (or `AR` via `MllpAckError`). **A positive ACK cannot
106
- precede a successful commit.** `autoAck: 'AA'` *without* a handler is documented as a
107
- transport-accept: "received and framed", not "processed".
108
-
109
- ## What's in the box
110
-
111
- - **Client + server** with strict MLLP framing (`VT + payload + FS + CR`), ACK correlation, auto-reconnect with backoff, and backpressure.
112
- - **The commit contract**: a positive ACK can never precede a durable commit; an unparseable inbound can never yield a positive ACK.
113
- - **Explicit 6-state connection machine** (`CONNECTING | CONNECTED | DRAINING | RECONNECTING | DISCONNECTED | CLOSED`) with `stateChange` events, never socket flags.
114
- - **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.
115
- - **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.
116
- - **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.
117
- - **In-memory transport** (`@cosyte/mllp/testing`): a deterministic, socket-free test double for fast, reliable tests.
118
- - **`@cosyte/hl7` as an optional peer**: the `@cosyte/mllp/ack-from-hl7` subpath builds ACKs from parsed messages when it's installed.
197
+ The [TLS guide](https://github.com/cosyte/mllp/blob/main/docs-content/tls.md) has the `ClientAuth`
198
+ table, the TLS 1.2 floor (IHE ATNA ITI-19), the typed failure modes (`tls-verify` against
199
+ `tls-handshake`), and the bind-safety details.
200
+
201
+ ## PHI and safety
202
+
203
+ An MLLP frame carries an HL7 v2 message, and an HL7 v2 message carries patient data. This package is
204
+ the transport under that traffic, so what it does with those bytes is a safety property rather than a
205
+ footnote.
206
+
207
+ **What it does with your payload.** It moves the buffer you hand it to the peer and hands the peer's
208
+ bytes back to your handler. It holds a message in memory only while that message is in flight: from
209
+ `send()` until its acknowledgement is correlated, or until `close()` drains it or reports it
210
+ unresolved. On the receive side your handler is given a copy, and the decoder then reuses its
211
+ internal accumulator for the next frame rather than zeroing it, so those bytes stay in that private
212
+ buffer until a later frame overwrites them, for as long as that reader lives. That is buffer reuse
213
+ rather than storage, and no public API hands the buffer out, but it is not a scrub.
214
+
215
+ **The reads the transport performs, and where they stop.** The transport does not parse HL7, but it
216
+ is not blind to the payload either, and the bound is worth more to you than the slogan. To answer a
217
+ message it has to correlate the answer, so it locates the `MSH` segment and reads the header fields
218
+ an acknowledgement is built from and answered with: `MSH-10`, echoed byte for byte into `MSA-2`, the
219
+ sending and receiving application and facility, which swap sides in the reply, the declared
220
+ delimiters, processing id and version, and the acknowledgement mode in `MSH-15` and `MSH-16`.
221
+ **That scan is bounded at the `MSH` segment's own terminator**, so it reaches nothing past the
222
+ header: on the transport path `PID`, and every other segment carrying clinical content, is never
223
+ read, never decoded and never echoed. Reading an inbound acknowledgement adds one segment and no
224
+ more, `MSA`, for its acknowledgement code and the control id it echoes back, decoded under the same
225
+ bound at that segment's own terminator. Those two are the whole of it, in one place, shared by the
226
+ client's correlator and both acknowledgement builders. Separately, the leading identifier of each
227
+ segment is checked so that a batch envelope or a second `MSH` can be refused rather than falsely
228
+ acknowledged; no field of those segments is read.
229
+
230
+ **The exception, and it is opt-in: `@cosyte/mllp/ack-from-hl7`.** That subpath is a parser front end
231
+ rather than transport. Hand `buildMllpAck` or `detectMode` a payload and this package decodes the
232
+ whole message, later segments included, and gives it to the optional
233
+ [`@cosyte/hl7`](https://github.com/cosyte/hl7) peer, which resolves every field of every segment,
234
+ `PID` among them. That decode is this package's own code and it ships in this package's tarball, so
235
+ count it as yours rather than the peer's. Nothing reaches it by default: the client, the server and
236
+ `buildRawAck` never import that subpath, and the peer is an optional dependency you install
237
+ deliberately. If your data-flow boundary turns on later segments never being materialised as decoded
238
+ text, that is the subpath to leave out.
239
+
240
+ **What it does not do.** Outside that subpath it does not parse the message, and outside the two
241
+ bounded segment reads above it does not inspect it. It writes nothing to disk: there is no queue, no
242
+ write-ahead log, no replay store and no cache anywhere in this package. Library code calls no logger
243
+ and no `console` method.
244
+
245
+ **What its diagnostics can carry.** Shape rather than content: no error, warning, event payload or
246
+ stats object ever echoes a run of message content.
247
+
248
+ - A **framing error** carries at most the single byte at the structural violation, plus that byte's
249
+ offset.
250
+ - A **warning message** carries structural facts: byte offsets, counts and accumulated sizes. Two
251
+ framing codes are the exception worth knowing about. `MLLP_MISSING_LEADING_VT` and
252
+ `MLLP_FS_WITHOUT_CR` render the hex of the single byte found where a framing byte was expected,
253
+ and on a stream that omits its leading `VT` that byte is the first byte of the unframed content.
254
+ One byte, never a run, the same bound a framing error carries. If that is more than your threat
255
+ model allows, log `code` and `byteOffset` rather than `message`.
256
+ - The **acknowledgement-correlation diagnostics** are stricter. Their text comes from a frozen
257
+ registry keyed on the code alone, with no value parameter anywhere in it, so a control id cannot
258
+ reach one; they report byte lengths instead of bytes.
259
+ - `getStats()` returns plain JSON-serialisable counters, and the two security warnings raised
260
+ through `process.emitWarning` carry their code and a fixed description. The wildcard-bind warning
261
+ names the address you bound, which is your own configuration, never message content.
262
+
263
+ **What you still own.**
264
+
265
+ - **Encryption in transit.** A plain MLLP connection is cleartext on the wire. TLS is built in, but
266
+ you have to turn it on and supply the material.
267
+ - **Logging and retention.** The moment you log a payload, an acknowledgement body or a control id,
268
+ it is yours to protect. Nothing here stores a message, so nothing here can delete one on request
269
+ either.
270
+ - **Idempotency.** MLLP plus an acknowledgement is at-least-once at best. De-duplicate on `MSH-10`
271
+ plus `MSH-7` in your application.
272
+ - **Exposure.** The `127.0.0.1` default and the wildcard-bind opt-in are guardrails, not a network
273
+ policy. Widening either is your decision.
274
+
275
+ Every example on this page uses placeholder application and facility names and a synthetic control
276
+ id. No sample here carries a name, a date of birth, an identifier or an address, and none should.
277
+
278
+ ## API
279
+
280
+ Three code subpaths ship, plus `@cosyte/mllp/package.json`:
281
+
282
+ - **`@cosyte/mllp`** is the whole transport: `createServer` and `createStarterServer`, `createClient`
283
+ and `createStarterClient`, `encodeFrame` and `FrameReader`, `Connection`, `buildRawAck`, the typed
284
+ errors, and the differential harness (`runDifferential`).
285
+ - **`@cosyte/mllp/testing`** is `InMemoryTransport`, a deterministic socket-free test double. Every
286
+ test that can run over it should.
287
+ - **`@cosyte/mllp/ack-from-hl7`** builds acknowledgements from a parsed message, or from raw bytes it
288
+ parses through the peer, and is the one place the optional `@cosyte/hl7` peer is needed.
289
+
290
+ What comes with them:
291
+
292
+ - **Strict framing** (`VT + payload + FS + CR`), acknowledgement correlation, auto-reconnect with
293
+ backoff, and backpressure.
294
+ - **A lenient decoder and a strict encoder** (Postel's Law) with **11 stable warning codes** carrying
295
+ byte offsets. Tolerance is opt-in per flag; the server ships tolerant defaults.
296
+ - **An explicit 6-state connection machine**
297
+ (`CONNECTING | CONNECTED | DRAINING | RECONNECTING | DISCONNECTED | CLOSED`) with `stateChange`
298
+ events, never socket flags.
299
+ - **`AbortSignal` on every awaitable** and `Symbol.asyncDispose` on every closeable.
300
+ - **A drain on `close()`** that tells a message which was never written apart from one whose fate is
301
+ unknown, because only one of those is safe to resend.
119
302
  - **Zero runtime dependencies.** Node stdlib only.
120
303
 
121
- ## What it deliberately does not do
304
+ Full reference: [the documentation](https://github.com/cosyte/mllp/tree/main/docs-content).
305
+
306
+ ## Compatibility
307
+
308
+ - **Node.js 22 and 24.** `engines.node` is `>=22.0.0` and CI runs both.
309
+ - **HL7 v2 payloads are bytes here, and the exceptions are named.** The transport does not parse
310
+ HL7: it reads the `MSH` header its acknowledgement is built from, and the `MSA` of an inbound
311
+ acknowledgement, each bounded at that segment's own terminator. The opt-in `ack-from-hl7` subpath
312
+ is the one place a whole message is parsed. [PHI and safety](#phi-and-safety) sets all of that out
313
+ in full. For actual parsing, pair this with [`@cosyte/hl7`](https://github.com/cosyte/hl7), which
314
+ is exactly what that subpath does.
315
+ - **Differentially verified against freely available engines**: Mirth Connect from NextGen, and the
316
+ Google Cloud Healthcare MLLP adapter. **Epic and Cerner are not part of that harness**, and no
317
+ claim about either is made here. `runDifferential` ships in the published artifact, so you can aim
318
+ it at your own test or staging endpoint and get a per-exchange frame-parity and `MSA-2` correlation
319
+ report for the engine you actually integrate with.
320
+ - **Known gaps, stated rather than hidden.** No batch acknowledgement, no MLLP Release 2, no queue or
321
+ replay of unacknowledged messages, no clinical acceptance decision, and no PKI. The
322
+ [known limitations](https://github.com/cosyte/mllp/blob/main/docs-content/limitations.md) page is
323
+ the full list. Read it before you depend on this.
324
+
325
+ ## Contributing
326
+
327
+ Questions, bug reports and interoperability findings belong in
328
+ [GitHub issues](https://github.com/cosyte/mllp/issues). Pull requests are welcome, and there is a gap
329
+ worth naming: this repository carries no contributor guide yet, so open an issue before a large
330
+ change so the approach can be agreed first.
331
+
332
+ A contribution must clear every gate this repository ships, all of them scripts you can run yourself:
333
+
334
+ ```bash
335
+ pnpm typecheck
336
+ pnpm lint
337
+ pnpm test
338
+ pnpm format:check
339
+ pnpm check:no-emdash
340
+ pnpm check:no-internal-refs
341
+ pnpm check:agent-notes
342
+ pnpm phi-scan
343
+ pnpm build
344
+ pnpm changeset # every meaningful change carries one
345
+ ```
122
346
 
123
- MLLP + ACK is **at-least-once at best**. Your application owns idempotency and de-duplication
124
- (`MSH-10` + `MSH-7`). This package does not parse HL7 (use `@cosyte/hl7`), does not queue or replay
125
- unacked messages, does not decide clinical acceptance, does not speak MLLP **Release 2**, and ships
126
- no PKI. The full list is in the **Known limitations & non-goals** doc. Read it before you depend on
127
- this.
347
+ Never commit real patient data, in a test fixture or anywhere else. `pnpm phi-scan` runs on every
348
+ commit and is deliberately hard to bypass.
128
349
 
129
350
  ## Trademarks
130
351
 
131
- Epic, Cerner, Mirth Connect, NextGen, and Google Cloud Healthcare are trademarks of their respective owners. cosyte is not affiliated with, endorsed by, or
132
- 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).
352
+ Epic, Cerner, Mirth Connect, NextGen, and Google Cloud Healthcare are trademarks of their respective
353
+ owners. cosyte is not affiliated with, endorsed by, or sponsored by any of them. The names identify
354
+ the engines this package is tested against, and those it is not. See
355
+ [TRADEMARKS.md](./TRADEMARKS.md).
133
356
 
134
357
  ## License
135
358
 
136
- MIT
359
+ MIT, Copyright (c) 2026 Cosyte. See [LICENSE](./LICENSE).
@@ -153,12 +153,20 @@ function stripLeadingSegmentTerminators(buf) {
153
153
  return i === 0 ? buf : buf.subarray(i);
154
154
  }
155
155
  function extractMshControlId(buf) {
156
- const msh = readMshSegment(buf);
156
+ return controlIdFromMsh(readMshSegment(buf));
157
+ }
158
+ function controlIdFromMsh(msh) {
157
159
  if (msh === null) return null;
158
160
  const id = msh.fields[9];
159
161
  return id === void 0 || id === "" ? null : id;
160
162
  }
161
163
  function extractMsaControlId(buf) {
164
+ const msa = readMsaSegment(buf);
165
+ if (msa === null) return null;
166
+ const id = msa.fields[2];
167
+ return id === void 0 || id === "" ? null : id;
168
+ }
169
+ function readMsaSegment(buf) {
162
170
  const msh = readMshSegment(buf);
163
171
  if (msh === null) return null;
164
172
  const fieldSep = msh.fieldSep.charCodeAt(0);
@@ -170,21 +178,8 @@ function extractMsaControlId(buf) {
170
178
  segEnd++;
171
179
  }
172
180
  if (segEnd - segStart >= 4 && buf[segStart] === ASCII_M && buf[segStart + 1] === ASCII_S && buf[segStart + 2] === ASCII_A && buf[segStart + 3] === fieldSep) {
173
- let fieldIndex = 0;
174
- let fieldStart = 0;
175
- for (let i = segStart + 3; i <= segEnd; i++) {
176
- const b = i < segEnd ? buf[i] : fieldSep;
177
- if (b === fieldSep || b === SEGMENT_SEPARATOR_CR || b === SEGMENT_SEPARATOR_LF) {
178
- fieldIndex++;
179
- if (fieldIndex === 2) {
180
- fieldStart = i + 1;
181
- } else if (fieldIndex === 3) {
182
- if (fieldStart >= i) return null;
183
- return buf.subarray(fieldStart, i).toString(CONTROL_ID_ENCODING);
184
- }
185
- }
186
- }
187
- return null;
181
+ const fields = buf.subarray(segStart, segEnd).toString(CONTROL_ID_ENCODING).split(msh.fieldSep);
182
+ return { fieldSep: msh.fieldSep, fields };
188
183
  }
189
184
  while (segEnd < end && (buf[segEnd] === SEGMENT_SEPARATOR_CR || buf[segEnd] === SEGMENT_SEPARATOR_LF)) {
190
185
  segEnd++;