Quickstart
@cosyte/mllp is transport, not parsing: it frames HL7 v2 bytes onto the wire, correlates the
ACK that comes back, and never inspects the payload. This page starts with the program the README
opens with, a server and a client on loopback, then goes down to the frame (VT + payload + FS + CR)
that everything else (the client, the server, reconnect, TLS) is built on.
Send a message and read the acknowledgement
Start a server, send it a message, and read the acknowledgement it built. Auto-ACK is on by default, and the server awaits your handler, which is the durable commit step, before it answers. The payload API is Buffer-first everywhere: HL7 v2 messages are raw bytes with caller-managed charset decoding.
import { createStarterClient, createStarterServer } from "@cosyte/mllp";
const committed: Buffer[] = [];
// Port 0: the OS picks a free port. The host defaults to loopback, 127.0.0.1.
const server = await createStarterServer({
port: 0,
onMessage: async (payload) => {
committed.push(payload); // your durable commit: a throw here answers AE, never AA
},
});
const client = await createStarterClient({ host: "127.0.0.1", port: server.getStats().port ?? 0 });
const ack = await client.send(
Buffer.from(
"MSH|^~\\&|SENDING_APP|SENDING_FAC|RECEIVING_APP|RECEIVING_FAC|20260101120000||ADT^A01|CTRL0001|P|2.5.1\r",
),
);
// Log the shape of the acknowledgement, never its field values.
const msa = ack
.toString("utf8")
.split("\r")
.find((segment) => segment.startsWith("MSA|"))
?.split("|");
console.log("acknowledgement code:", msa?.[1]);
console.log("MSA-2 echoes the control id sent:", msa?.[2] === "CTRL0001");
console.log("messages committed:", committed.length);
await client.close();
await server.close();
acknowledgement code: AA
MSA-2 echoes the control id sent: true
messages committed: 1
MSA-2 echoes the control id you sent, byte for byte, which is what lets the client match the answer
to the message. The acknowledgement's own MSH-7 and MSH-10 change on every run. A handler that
throws is answered AE, never AA: see ACKs & the commit contract.
Frame a message, read it back
encodeFrame wraps a payload in the canonical MLLP envelope; FrameReader de-frames a byte stream
back into payloads. The encoder is strict (it always emits VT + payload + FS + CR) and the
reader hands each complete payload to onFrame, regardless of how the bytes were chunked on the
wire:
import { encodeFrame, FrameReader } from "@cosyte/mllp";
// A synthetic HL7 v2 message: bytes the transport carries and never reads.
const payload = Buffer.from("MSH|^~\\&|SEND|FAC|RECV|FAC|20260717||ADT^A01|MSG00001|P|2.5");
const frame = encodeFrame(payload);
// Canonical framing: <VT> … <FS> <CR>.
frame[0]; // => 0x0b
frame[frame.length - 2]; // => 0x1c
frame[frame.length - 1]; // => 0x0d
// Feed the framed bytes back through a reader; the payload comes out byte-for-byte.
const received: Buffer[] = [];
new FrameReader({ onFrame: (p) => received.push(p) }).push(frame);
received.length; // => 1
received[0]?.equals(payload); // => true
The payload round-trips exactly. The transport adds and strips three delimiter bytes and changes nothing in between. What those bytes mean is the HL7 parser's job, not this package's.
Tolerate a real sender's quirks, loudly
Real senders drop the leading <VT>, append a stray <LF>, or pad with whitespace. A bare
FrameReader is strict by default; you opt in to each tolerance flag by flag, and every
tolerated deviation surfaces as a warning with a stable code rather than passing silently
(Postel's Law):
import { FrameReader } from "@cosyte/mllp";
// A sender padded the frame with a leading space before <VT>.
const padded = Buffer.from([0x20, 0x0b, 0x41, 0x1c, 0x0d]); // SP, VT, "A", FS, CR
const codes = [];
const reader = new FrameReader({
onFrame: () => {},
onWarning: (w) => codes.push(w.code),
allowLeadingWhitespace: true, // opt in to tolerate the padding
});
reader.push(padded);
codes.includes("MLLP_LEADING_WHITESPACE"); // => true
The warning codes are a public, versioned contract. Log pipelines key on them, so renaming one
is a breaking change. MllpServer ships tolerant defaults because it is the side that must accept
what real senders emit; see Framing & tolerance for the flag-by-flag table.
The encoder refuses an unframable payload
MLLP is not byte-transparent: the delimiters are literal byte values, so a payload must not
itself contain 0x0B (<VT>) or 0x1C (<FS>). Rather than emit a frame a peer would mis-split,
the strict encoder throws:
import { encodeFrame } from "@cosyte/mllp";
// The payload contains a raw <FS> (0x1C). It cannot be framed unambiguously.
encodeFrame(Buffer.from([0x41, 0x1c, 0x42])); // throws MllpFramingError (MLLP_PAYLOAD_CONTAINS_FS)
That is why a payload's charset matters to the transport: UTF-16/UTF-32 put those bytes inside ordinary characters. Use a single-byte encoding, UTF-8, or Shift_JIS. See Known limitations.
Send a message over a connection
On a real link, createStarterClient is the batteries-included path (auto-reconnect on, sensible
backoff and backpressure). send() resolves with the ACK correlated to your message, not with
whatever bytes arrive next:
import { createStarterClient } from "@cosyte/mllp";
await using client = await createStarterClient({ host: "127.0.0.1", port: 2575 });
const ack = await client.send(Buffer.from(rawHl7)); // resolves on the correlated ACK frame
// Pass an AbortSignal to bound any await; the client is disposed on scope exit.
send never resolves without its ACK, so a message can never silently "deliver". Reconnects,
backoff, and backpressure are handled for you. See
Connection, reconnect & backpressure.
Receive messages: with the commit contract
Server-side, pair autoAck: 'AA' with an onMessage handler and the server treats your handler
as the durable-commit step: it awaits it, and only then acknowledges: AA on success, a
negative code if your handler throws. A positive ACK can never precede a successful commit:
import { createServer } from "@cosyte/mllp";
const server = createServer({
autoAck: "AA",
onMessage: async (payload, meta) => {
// `payload` is a raw Buffer. Charset decoding stays with you.
await db.commit(payload); // throw here ⇒ AE (a resend may succeed), never AA
},
});
await server.listen(2575, "127.0.0.1");
This is the page to internalize before you put the package in front of a clinical system. Read ACKs & the commit contract next.
About runnable examples. The blocks tagged
```ts runnableabove are extracted by the docs build, executed against the package, and their// =>results asserted, so a documented example can never silently drift from the code. The first one opens a loopback socket on a port the operating system picks and closes both ends, so it runs as it stands. The two client and server blocks after the framing examples name a fixed port and a database of yours, so they are shown as plain```tsillustrations. For socket-free integration tests, the@cosyte/mllp/testingsubpath's in-memory transport wires aConnectionend-to-end with no ports and no certs: Testing & verification.
Next
- Framing & tolerance: the wire format, the opt-in tolerance flags, and the stable warning-code registry.
- ACKs & the commit contract: the page to read before a clinical deployment.
- Connection, reconnect & backpressure: the 6-state machine, backoff, and load shedding.
- Testing & verification: the socket-free in-memory transport, and the differential harness for checking a real engine before go-live.
- Known limitations & non-goals: what not to trust this transport to do.