Crates
hl7-2-mllp
MLLP: HL7 v2 framed on a TCP stream
What it is
A TCP stream is bytes without edges, and an HL7 v2 message carries no length prefix and no self-delimiting syntax — so a receiver reading a socket cannot tell where one message stops and the next begins. MLLP is the three-byte answer to that, and nothing more:
<VT> message <FS><CR>
0x0B 0x1C 0x0DThat is the whole protocol. No length, no checksum, no session, no negotiation, no encryption. What people actually need on top of it — whole messages out of a chopped-up stream, an acknowledgement that names the message it answers, and a way to bound what a broken peer can allocate — is what this crate provides.
cargo add hl7-2-mllp
cargo add hl7-2-mllp --no-default-features # framing only, zero dependenciesFraming
use hl7_2_mllp as mllp;
let frame = mllp::encode(message.as_bytes());
assert_eq!(frame[0], mllp::START_BLOCK);
assert_eq!(mllp::decode(&frame)?, message.as_bytes());The payload is copied verbatim — not trimmed, not validated, not normalized. A message's own \r segment terminators are the same byte as the frame's trailer, and survive
untouched.
Streaming
The one a socket needs. Frames arrive split across reads, several to a read, or both, and Framer is the small amount of state that puts them back together.
use hl7_2_mllp::Framer;
let mut framer = Framer::new();
framer.push(b"\x0bMSH|one\x1c\r\x0bMSH|t"); // one and a half messages
framer.push(b"wo\x1c\r"); // the other half
assert_eq!(framer.next_frame()?.unwrap(), b"MSH|one");
assert_eq!(framer.next_frame()?.unwrap(), b"MSH|two");
assert_eq!(framer.next_frame()?, None); // nothing more yetA partial frame is Ok(None), not an error — it means “read more” — and a frame may
be split anywhere, including between <FS> and its <CR>.
Because MLLP has no length field, a Framer also caps what it will buffer (16 MiB by
default), so a peer that never sends an end block cannot grow the process until it dies.
Transport
use hl7_2_mllp::{IoTransport, Transport};
use std::net::TcpListener;
let listener = TcpListener::bind("127.0.0.1:2575")?;
for stream in listener.incoming() {
let mut transport = IoTransport::new(stream?);
while let Some(message) = transport.receive()? {
// ... one whole HL7 message ...
}
}IoTransport works over anything that reads and writes bytes — a TcpStream, a TLS stream, a Unix socket, a buffer in a test — and the Transport trait is there for carriers it does not know about.
Acknowledgement
MLLP has no acknowledgement of its own. The reply HL7 expects is an HL7 message: an ACK whose MSA-2 echoes the control ID of the message being answered.
use hl7_2_mllp::{AckCode, ack};
let frame = ack::acknowledge(&payload, AckCode::Accept, "ACK00001", "20260814080100")?;
transport.send(hl7_2_mllp::decode(&frame)?)?;That echo is the whole mechanism. MLLP guarantees a message arrived whole; only the echoed control ID says which message arrived — so a sender that does not compare it will eventually take one answer for another's.
When the receiver needs to look before it answers, which is the usual case:
let message = ack::parse(&payload)?;
let mut nack = ack::acknowledge_message(&message, AckCode::Error, "N1", "20260814080100")?;
nack.set("MSA-3", "OBR-4 is required")?;
transport.send(nack.to_er7().as_bytes())?;Every call takes the acknowledgement's own control ID and timestamp as arguments, because a
message that invents them is untestable and untraceable. The clock feature adds acknowledge_now for callers who genuinely just want the current time.
Strictness
By default a frame must start with <VT>, end with <FS><CR>, and contain neither block character in between. Real senders
are not always strict, so the noncompliance feature forgives the two common sins —
a missing <CR> after <FS>, and stray bytes between frames
— and nothing else.
It is off by default because a receiver that quietly accepts malformed framing is how a truncated message becomes a clinical record. Either tolerance is always reachable by name, whatever the features say:
use hl7_2_mllp::{Framer, Tolerance};
let framer = Framer::new().with_tolerance(Tolerance::Lenient); // for that one senderFeatures
See the table above. --no-default-features gives framing, streaming, and transport
with no dependencies at all.
Examples
Two programs that talk to each other:
cargo run --example tcp_listener # accepts, reads, acknowledges
cargo run --example tcp_sender # sends, waits, checks the echoThe listener is commented with what it shows and what a production listener also needs — TLS, a read timeout, a connection bound, and persistence before acknowledging. Building one step by step: An MLLP listener that answers.
What this crate does not do
MLLP is a small protocol and this is a small crate. It has no TLS (compose it — IoTransport takes any stream), no async runtime, no connection pooling, no retry or
reconnect policy, and no opinion on HL7 v2 semantics.
Sending AA promises the message is safe; making that true before you send it is your
application's job, and no library can do it for you.
Related crates
hl7-2
HL7 v2 itself: parse, navigate, validate, modify, render
hl7-2-soap
HL7 v2 carried in a SOAP envelope over HTTP