Skip to main content
Version: v0.1.8

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 acknowledgment​

Start a server, send it a message, and read the acknowledgment it built. The starter server turns auto-ACK on, and it 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 acknowledgment's own MSH-7 and MSH-10 change on every run. A handler that throws is answered AE, never AA: see ACKs and 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: string[] = [];
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 Tolerate a sender's framing quirks 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.

Next​