Skip to main content
Version: v0.1.0

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 runnable above 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 ```ts illustrations. For socket-free integration tests, the @cosyte/mllp/testing subpath's in-memory transport wires a Connection end-to-end with no ports and no certs: Testing & verification.

Next​