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. The core primitive is the frame (`VT + payload
- FS + CR`) and everything else (the client, the server, reconnect, TLS) is built on it. This page starts there, then shows the client and server you will actually deploy.
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.
let received;
new FrameReader({ onFrame: (p) => (received = p) }).push(frame);
received.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. They stay at the framing layer because it runs deterministically in-process; the client/server blocks open real sockets, 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.
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.
- Known limitations & non-goals: what not to trust this transport to do.