node-hl7-client
v4.1.0
Published
A pure Node.js HL7 Client that allows for communication to a HL7 Broker/Server that can send properly formatted HL7 messages with ease.It can also parse and then you can extract message segments out.
Maintainers
Readme
🩺 Node HL7 Client
A pure TypeScript HL7 client/builder/parser for Node.js — build, send, parse, and reply to HL7 v2.x messages over MLLP. For the receiving side, see the companion
node-hl7-serverpackage.
node-hl7-client is a lightweight, dependency-free library for healthcare integrations. It speaks the traditional TCP/MLLP transport, ships typed builders for every HL7 v2.1 → 2.8 segment, parses inbound responses, and includes the MLLPCodec re‑used by node-hl7-server.
⭐ Now part of the
node-hl7monorepo. The original standalonenode-hl7-clientrepo collected a lot of stars over the years — thank you! Stars don't carry over to the new home, so if this package has been useful to you, please drop a ⭐ on the monorepo so the new repo reflects the real community size. 🙏
✨ Features
- ⚡ Zero runtime dependencies — fast, small, easy to audit.
- 🧱 Typed segment builders —
HL7_2_1throughHL7_2_8, withbuildMSH,buildPID,buildEVN,buildOBX,buildORC, … all the segments you actually use. - 🧮 Per-version field availability — every segment carries an HL7 v2 usage code per version (R/O/B/W/D/X), sourced from the Caristix HL7 Definition API. Withdrawn fields throw, deprecated (B) fields warn, segments that didn't exist in your version are rejected. The full catalogue is exported as
SEGMENT_SPECS. - 🔗 Chainable builders — every
build*returns the builder, so you can composenew HL7_2_8().buildMSH(...).buildPID(...).toString()top-to-bottom. - 🧰
buildSegment(name, props)— universal spec-driven builder for the long tail of ~187 segments when a hand-tuned method isn't available. - 🧬 Typed composite inputs — composite fields (
XAD,XPN,CWE,CX,EI,HD, …) accept either a^-delimited string or a typed object:pid_11: { streetAddress: "123 Elm St", city: "Springfield", stateOrProvince: "IL" }. Per-component length, required, withdrawn, and not-supported rules are enforced. - 🔁 Auto reconnect & retry — exponential backoff, configurable attempt cap.
- 🧠 Pluggable queue — default in‑memory, or wire it up to Redis / RabbitMQ / SQL.
- 📦 Builder + Parser + Client — one package covers send, receive, and round‑trip.
- 💻 Cross‑platform — Windows, macOS, Linux.
- 🤝 Companion server — pair with
node-hl7-serverfor full coverage.
📦 Install
npm install node-hl7-client🟢 Requires Node.js ≥ 22.
🧾 Table of Contents
- Quick Start
- Building a Message (the new class‑based builder)
- Building Batches & Files
- Sending a Message
- TLS
- Mutual TLS (mTLS)
- Parsing Replies
- Custom Queues (Redis, etc.)
- Architecture
- Detailed Docs
- Keyword Definitions
- License
🚀 Quick Start
import Client, { HL7_2_5 } from "node-hl7-client";
// 1) Build an ADT^A01. Every build* returns the builder, so you can chain.
const message = new HL7_2_5()
.buildMSH({
msh_3: "MY_APP",
msh_4: "MY_FAC",
msh_5: "EPIC",
msh_6: "HOSP",
msh_9_1: "ADT",
msh_9_2: "A01",
msh_10: "MSG00001",
msh_11_1: "P",
})
.buildEVN({ evn_1: "A01" })
.buildPID({
pid_3: "MRN12345",
pid_5: "DOE^JANE^A",
pid_7: new Date("1980-01-01"),
pid_8: "F",
})
.toMessage();
// 2) Open a persistent connection and send it. The client version is required
// and must match the MSH.12 of every message you send through it.
const client = new Client({ host: "127.0.0.1", version: "2.5" });
const conn = client.createConnection({ port: 3000 }, async (res) => {
console.log("✅ ACK:", res.getMessage().get("MSA.1").toString()); // AA
});
await conn.sendMessage(message);The class‑based builder validates segment fields against HL7 tables (e.g. allowed values for MSH.11, MSA.1, PV1.2) and against per-version usage codes from the published HL7 spec — it rejects withdrawn fields (W/X), warns on backward-compatibility ones (B), and refuses segments that didn't exist in the active version. Bad input raises HL7ValidationError; the result is a real Message you can keep mutating with message.set("PID.13", ...), message.addSegment("OBX"), and so on.
🧱 Building a Message (the new class‑based builder)
flowchart LR
A[HL7_2_x instance] --> B[buildMSH<br/>required]
B --> C[buildEVN<br/>buildPID<br/>buildOBR<br/>buildOBX<br/>buildORC<br/>buildPV1<br/>...]
C --> D[toMessage / toString]
D --> E[ ✉️ Message ready to send]Step 1 — Pick a version
import { HL7_2_3, HL7_2_4, HL7_2_5, HL7_2_6, HL7_2_7, HL7_2_8 } from "node-hl7-client";
const builder = new HL7_2_5({
// Optional: override the default date format.
// "8" = YYYYMMDD, "12" = YYYYMMDDHHMM, "14" = YYYYMMDDHHMMSS (default).
date: "14",
// Optional: hardError = true makes validation issues throw immediately.
hardError: true,
});Step 2 — Build MSH (always first)
builder.buildMSH({
msh_3: "SENDING_APP",
msh_4: "SENDING_FAC",
msh_5: "RECEIVING_APP",
msh_6: "RECEIVING_FAC",
msh_9_1: "ADT", // message code (MSH.9.1) — required
msh_9_2: "A01", // trigger event (MSH.9.2) — required on 2.2+
msh_10: "MSG00001", // control id; auto‑randomized if omitted
msh_11_1: "P", // processing ID (MSH.11.1) — P = production, T = test
});⚠️ Calling any other
build*method beforebuildMSHthrowsHL7FatalError("MSH Header must be built first.").
Step 3 — Build segments
Each version‑specific class exposes the segments that are valid for that version. Common ones include:
| Builder | Segment | Notes |
|---|---|---|
| buildEVN(props) | EVN | Event type/timestamps for ADT messages. |
| buildPID(props) | PID | Patient identification. |
| buildPV1(props) | PV1 | Patient visit. |
| buildOBR(props) | OBR | Observation request. |
| buildOBX(props) | OBX | Observation result. |
| buildORC(props) | ORC | Common order. |
| buildNTE(props) | NTE | Notes & comments. |
| buildMSA(props) | MSA | Used when building ACKs by hand. |
| buildERR(props) | ERR | Error segment. |
builder.buildEVN({ evn_1: "A01", evn_2: new Date() });
builder.buildPID({
pid_3: "MRN12345", // patient id
pid_5: "DOE^JANE^A", // last^first^middle
pid_7: new Date("1980-01-01"), // DOB (Date or string)
pid_8: "F", // sex (validated against TABLE_0001)
pid_11: "123 ELM ST^^SPRINGFIELD^IL^62701", // address
});
builder.buildOBX({
obx_1: "1",
obx_2: "TX",
obx_3: "NOTE^Discharge Note^L",
obx_5: "Patient stable, discharged home.",
obx_11: "F", // status (validated against TABLE_0085)
});💡 Most fields accept either positional names (
pid_5) or human‑friendly aliases (patientName). Pick whichever is clearer at the call site.
🎨 Composite values: pass the whole HL7 string
The builder treats every prop as a literal field value, so you can embed HL7 delimiters directly instead of building components piece‑by‑piece. This is what makes the typed builder feel fun — short, declarative, and easy to template from another data source.
builder.buildPID({
pid_3: "MRN12345", // patient id (required)
pid_5: "DOE^JANE^A", // last^first^middle
pid_11: "123 ELM ST^^SPRINGFIELD^IL^62701", // ^^ skips a component
pid_13: "555-0100~555-0200", // ~ separates repetitions
});
builder.buildOBX({ obx_3: "NOTE^Discharge Note^L", obx_11: "F" }); // identifier^text^codingSystem (+ status)⚠️ This works for fields whose components are optional.
MSH.9is the exception — its9.1/9.2are individually required, so passmsh_9_1/msh_9_2(andmsh_11_1on 2.3+), notmsh_9: "ADT^A01".
| Delimiter | Means | Example |
|:---:|---|---|
| ^ | next component | "DOE^JANE^A" |
| & | next sub‑component | "123 ELM ST&APT 4^^CITY" |
| ~ | next repetition | "555-0100~555-0200" |
| ^^ | leave a component empty | "ST^^CITY^STATE^ZIP" |
If you've changed the encoding characters via the builder constructor, use those characters in your composite strings instead — see composite docs.
Step 4 — Convert
const message = builder.toMessage(); // returns a Message
const text = builder.toString(); // returns the HL7 textThe resulting MSH for the example above:
MSH|^~\&|SENDING_APP|SENDING_FAC|RECEIVING_APP|RECEIVING_FAC|20240101000000||ADT^A01|MSG00001|P|2.5
EVN|A01|20240101000000
PID|||MRN12345||DOE^JANE^A||19800101|F|||123 ELM ST^^SPRINGFIELD^IL^62701
OBX|1|TX|NOTE^Discharge Note^L||Patient stable, discharged home.||||||F🛠️ Direct edits with message.set(...)
toMessage() returns a real Message you can keep mutating after the builder is done — useful for fields the builder doesn't surface:
const msg = builder.toMessage();
msg.set("PID.13", "555-0100"); // home phone
msg.set("PV1.7").set(0).set(1, "Jones").set(2, "John"); // chained repetitionsEncoding characters
Defaults are the HL7 standard: | field, ^ component, & subcomponent, ~ repetition, \ escape. To send through a system that uses non‑standard delimiters, set them once on the builder options:
const builder = new HL7_2_5({
separatorField: "!",
separatorComponent: "+",
separatorSubComponent: "]",
separatorRepetition: "?",
separatorEscape: "#",
});These cannot be changed via set() — they're embedded in MSH.1 and MSH.2.
📚 Building Batches & Files
import { Batch, FileBatch, HL7_2_5 } from "node-hl7-client";
// A batch is just multiple messages wrapped in BHS / BTS.
const batch = new Batch();
batch.start();
batch.add(makeMessage("MSG00001"));
batch.add(makeMessage("MSG00002"));
batch.end();
await conn.sendMessage(batch);
// A "file batch" is wrapped in FHS / FTS — useful for flat files
// for legacy systems and audit trails.
const file = new FileBatch();
file.start();
file.add(makeMessage("MSG00001"));
file.add(makeMessage("MSG00002"));
file.end();
fs.writeFileSync("ADT.20240101.hl7", file.toString());Receivers process each inner message individually — node-hl7-server will invoke your handler once per message inside a batch or file.
📤 Sending a Message
import Client from "node-hl7-client";
const client = new Client({
host: "127.0.0.1",
version: "2.7",
});
const OB_ADT = client.createConnection(
{
port: 3000,
waitAck: true, // default: wait for ACK before next send
maxConnectionAttempts: 10, // reconnect attempts before giving up
},
async (res) => {
const status = res.getMessage().get("MSA.1").toString();
if (status !== "AA") console.warn("⚠️ rejected:", status);
},
);
await OB_ADT.sendMessage(message);
await OB_ADT.close();The connection is persistent; you can send many messages over a single TCP/MLLP socket.
🌐 IPv4 + IPv6 (Dual-Stack)
The client supports IPv4, IPv6, and FQDN hosts. It runs IPv4-only by default. Opt into dual-stack by setting both ipv4: true and ipv6: true — when the host name then resolves to both A (IPv4) and AAAA (IPv6) records, Node's Happy-Eyeballs algorithm races both attempts and uses whichever wins, so a remote with a stale or unreachable AAAA record silently falls back to its IPv4 address (and vice versa).
// IPv4 only (default)
const client = new Client({ host: "hl7.example.com", version: "2.7" });
// Dual-stack with auto-fallback (opt-in)
const dual = new Client({
host: "hl7.example.com",
ipv4: true,
ipv6: true,
version: "2.7",
});
// Force IPv6 only
const v6Only = new Client({ host: "fd00::42", ipv6: true, version: "2.7" });
// Pin a specific termination address (when the host has multiple)
const pinned = new Client({ host: "fe80::1234", ipv6: true, version: "2.7" });
// Tune Happy-Eyeballs cadence (defaults shown — only takes effect in dual-stack):
const tuned = new Client({
host: "hl7.example.com",
ipv4: true,
ipv6: true,
version: "2.7",
autoSelectFamily: true, // default
autoSelectFamilyAttemptTimeout: 250, // ms before racing the other family
});| Option | Meaning |
|---|---|
| (defaults) | IPv4 only — host literal must be IPv4 |
| ipv4: true, ipv6: true | dual-stack with Happy-Eyeballs fallback |
| ipv6: true only | force IPv6 — host literal must be IPv6 |
| autoSelectFamily: false | disable Happy-Eyeballs (use the OS-default order) |
💡 Passing only one of
ipv4/ipv6is treated as exclusive — IP literals are validated against that family. Setting both tofalsethrows.
🔒 TLS
Pass tls: true to use the system trust store, or a tls object for full tls.ConnectionOptions control:
import fs from "node:fs";
import path from "node:path";
import Client from "node-hl7-client";
const client = new Client({
host: "hl7.example.local",
version: "2.7",
tls: {
// ✅ Validate the server's certificate (production default).
rejectUnauthorized: true,
// 🪪 Self-signed / in-house CA? Provide it explicitly.
ca: fs.readFileSync(path.join("certs", "server-ca-crt.pem")),
},
});
const OB_ADT = client.createConnection({ port: 6661 }, async (res) => {
console.log("✅", res.getMessage().get("MSA.1").toString());
});
await OB_ADT.sendMessage(message);🚨 Set
rejectUnauthorized: truein production. Thefalseform skips cert validation entirely — fine for local dev, dangerous on the open network.
The shorthand tls: true is also accepted when the server uses a cert chained to a public CA already in Node's trust store:
const client = new Client({ host: "hl7.example.com", tls: true, version: "2.7" });🛡️ Mutual TLS (mTLS)
Many hospital networks require client-certificate authentication. Provide your own key + cert so the server can validate you in addition to validating the server cert:
import fs from "node:fs";
import path from "node:path";
import Client from "node-hl7-client";
const client = new Client({
host: "hl7.example.local",
version: "2.7",
tls: {
// 🔑 The client's identity — what the remote server validates.
key: fs.readFileSync(path.join("certs", "client-key.pem")),
cert: fs.readFileSync(path.join("certs", "client-crt.pem")),
// 🪪 The CA(s) you trust to issue the server's certificate.
ca: fs.readFileSync(path.join("certs", "server-ca-crt.pem")),
// ✅ Always validate the server cert.
rejectUnauthorized: true,
// (Optional) Pin the server's expected hostname when it differs from `host`.
// servername: "hl7.example.local",
},
});
const OB_ADT = client.createConnection({ port: 6661 }, async (res) => {
console.log("✅", res.getMessage().get("MSA.1").toString());
});
await OB_ADT.sendMessage(message);| Option | What it does |
|---|---|
| key + cert | Your client identity. The server validates these against its trusted CAs. |
| ca | The trusted issuer(s) for the server's certificate. |
| rejectUnauthorized | If true, the connection drops on any cert validation error. Always true in production. |
| servername | SNI / expected server hostname. Defaults to host; override only if the cert CN differs. |
| passphrase | Passphrase for an encrypted private key. |
💡 The matching server-side configuration lives in
node-hl7-server— see its TLS / mTLS docs.
🔍 Parsing Replies
The same Message class powers parsing:
import { Message, Batch, FileBatch } from "node-hl7-client";
const msg = new Message({ text: hl7String });
msg.get("MSH.9.1").toString(); // ADT
msg.get("PID.5.1").toString(); // DOE
// Batches and files:
new Batch({ text: hl7BatchString }).messages().forEach((m) => { /* ... */ });
new FileBatch({ fullFilePath: "ADT.20240101.hl7" }).messages().forEach(/* ... */);⚠️ The parser is strict — malformed HL7 throws. Wrap untrusted input in a try/catch.
🧰 Custom Queues (Redis, etc.)
By default outbound messages are queued in memory, capped at 10,000 per connection. For Kubernetes or any multi‑pod deployment, offload the queue to Redis (preferred) or another durable store so messages survive pod restarts:
import { createClient } from "@redis/client";
import Client, { Message, MessageItem, NotifyPendingCount } from "node-hl7-client";
const redis = createClient();
await redis.connect();
const enqueueMessage = async (msg: MessageItem, notify: NotifyPendingCount) => {
await redis.lPush("hl7queue", msg.toString());
await notify(await redis.lLen("hl7queue"));
};
const flushQueue = async (
callback: (m: MessageItem) => void,
notify: NotifyPendingCount,
) => {
while ((await redis.lLen("hl7queue")) > 0) {
const popped = await redis.blPop("hl7queue", 1);
if (popped?.element) {
callback(new Message({ text: popped.element }));
await notify(await redis.lLen("hl7queue"));
}
}
};
const client = new Client({ host: "127.0.0.1", version: "2.7" });
const conn = client.createConnection(
{ port: 3000, autoConnect: false, enqueueMessage, flushQueue },
async () => {},
);🔐 Tag messages per client instance if you share a queue across services — otherwise pod A might dequeue messages destined for pod B.
🏗️ Architecture
flowchart LR
subgraph App
A[your code] --> B[Builder<br/>HL7_2_x]
B --> C[Message / Batch / FileBatch]
C --> D[Connection]
end
D -- TCP / MLLP --> E[(remote HL7 server)]
E -- ACK --> D
D --> F[InboundResponse handler]📖 Detailed Docs
📚 Keyword Definitions
The terms MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL in this documentation follow RFC 2119 semantics.
⚠️ Capitalization matters. These keywords carry their RFC 2119 meaning only when written in ALL CAPS. The lowercase forms (
must,should,may, …) are normal English and are not normative.
🤝 Contributing
Contributions are welcome — bug fixes, new HL7 version coverage, more detailed docs, parser improvements, anything.
- 🍴 Fork the repo and create a topic branch off
main. - ✅ Add or update tests under
__tests__/client/for any behavior change. The full suite runs withnpx vitest run. - 🧹 Lint with
npm run lintfrom the repo root and format with the existing eslint config. - 📝 Use one of the issue templates when opening an issue, and reference the issue number in your PR description.
- 🚀 Open a PR against
main. CI will run lint + tests on every push.
For larger changes, please open a discussion first so we can align on scope before code review.
🙏 Acknowledgements
node-rabbitmq-client— connection design inspiration.artifacthealth/hl7parser— parser/builder design reference.
👨👩👧👦 Family
A special thanks to my wife, daughter, and son for their patience while I work in "geek mode." 💚
📄 License
MIT © Shane Froebel
