@cosyte/mllp 0.0.11 → 0.1.1
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 +220 -5
- package/README.md +301 -78
- package/TRADEMARKS.md +1 -1
- 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.d.cts +1 -1
- package/dist/ack-from-hl7/index.d.ts +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 +16 -11
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
|
|
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
|
-
>
|
|
10
|
+
> HL7 v2 over MLLP for Node.js, with framing, ACK correlation and reconnects handled for you.
|
|
11
11
|
|
|
12
12
|
[](https://www.npmjs.com/package/@cosyte/mllp)
|
|
13
13
|
[](https://github.com/cosyte/mllp/actions/workflows/ci.yml)
|
|
14
14
|
[](./LICENSE)
|
|
15
15
|
[](https://nodejs.org)
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
//
|
|
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:
|
|
87
|
+
port: 0,
|
|
38
88
|
onMessage: async (payload) => {
|
|
39
|
-
|
|
89
|
+
committed.push(payload); // your durable commit: a throw here answers AE, never AA
|
|
40
90
|
},
|
|
41
91
|
});
|
|
42
|
-
```
|
|
43
92
|
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
115
|
+
```text
|
|
116
|
+
acknowledgement code: AA
|
|
117
|
+
MSA-2 echoes the control id sent: true
|
|
118
|
+
messages committed: 1
|
|
49
119
|
```
|
|
50
120
|
|
|
51
|
-
|
|
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
|
-
|
|
124
|
+
### Framing on its own
|
|
54
125
|
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
159
|
+
### The commit contract
|
|
86
160
|
|
|
87
|
-
A positive acknowledgement (`AA`) tells the sender
|
|
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
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
|
132
|
-
|
|
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).
|
package/TRADEMARKS.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Trademarks
|
|
2
2
|
|
|
3
|
-
`@cosyte/mllp` is an independent open-source project.
|
|
3
|
+
`@cosyte/mllp` is an independent open-source project. Cosyte is **not affiliated with, endorsed by,
|
|
4
4
|
or sponsored by** any company named in this repository or its documentation.
|
|
5
5
|
|
|
6
6
|
## Why these names appear
|
|
@@ -153,12 +153,20 @@ function stripLeadingSegmentTerminators(buf) {
|
|
|
153
153
|
return i === 0 ? buf : buf.subarray(i);
|
|
154
154
|
}
|
|
155
155
|
function extractMshControlId(buf) {
|
|
156
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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++;
|