@cosyte/mllp

中文文档
0.1.14发布于 2026/11/12MITTypeScriptESM + CJS零依赖构建来源已验证近 30 天下载 2102

MLLP transport for HL7 v2 in Node.js and TypeScript: client and server, framing, ACK correlation, auto-reconnect, TLS, and an in-memory transport for tests.

原始使用文档
文档目录17 节
  1. Contents
  2. Why this exists
  3. Status
  4. Install
  5. Usage
  6. Framing on its own
  7. The commit contract
  8. Transport security (MLLPS)
  9. API
  10. Compatibility
  11. Documentation
  12. Support
  13. Security
  14. PHI and safety
  15. Contributing
  16. Trademarks
  17. License
The Cosyte logo on its own white ground: the icon beside the word Cosyte.

@cosyte/mllp

HL7 v2 over MLLP for Node.js, with framing, ACK correlation, and reconnects handled for you.

npm version CI License: MIT Node

An MLLP client and server for Node.js: the transport-only sibling to @cosyte/hl7.

Contents

Why this exists

HL7 v2 interfaces still move over MLLP, and the protocol stops at the frame: VT + payload + FS + CR and nothing else. Everything that decides whether an interface survives a bad night is left to the integrator, so every team writes the same acknowledgment correlator, the same reconnect loop, and the same backpressure guard, and one of them is subtly wrong. The nearest alternative is a full integration engine such as Mirth Connect, which brings a runtime, a database, and a deployment story you may not want inside a Node.js service. @cosyte/mllp is that transport on its own: a library you import, with zero runtime dependencies, an in-memory transport so your tests never open a socket, and one guarantee it will not trade away, which is that a positive acknowledgment cannot precede a durable commit.

Status

This README describes the 0.1 line. The public API is settled and safe to depend on: the client, the server, the framing codec, the in-memory transport, and the optional ack-from-hl7 bridge are what this release commits to. The stable warning codes and security-warning codes are part of that surface, so renaming or removing one is a breaking change.

Named rather than implied, here is what is still moving:

  • Batch acknowledgment is not built. An HL7 batch envelope (FHS/BHS, HL7 v2.5.1 §2.10.3) or a frame carrying more than one MSH yields a warned, non-positive AE. That is deliberate, since a positive acknowledgment correlated to the first message would tell the sender a whole batch was accepted while the rest went unread, but it means the surface that would carry batch support does not exist yet.
  • Verified engine coverage is a growing list, not a complete one. The differential harness runs against freely available engines only. See Compatibility for who is in it and who is not.
  • MLLP Release 2 is not spoken, and no option selects it.

Install

pnpm add @cosyte/mllp

# optional peer, needed only for the ack-from-hl7 subpath
pnpm add @cosyte/hl7

Node.js 22 or later, tested on 22, 24, and 26 (engines.node is >=22.0.0). The package ships dual ESM (import) and CommonJS (require) builds, each with its own type declarations, and has zero runtime dependencies. The optional @cosyte/mllp/arbitraries entry point, fast-check generators for property-testing your own code, needs fast-check installed beside it; the main entry point never loads it.

Usage

Start a server, send it a message, and read the acknowledgment 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 acknowledgment's own MSH-7 and MSH-10 change on every run.

Framing on its own

encodeFrame and FrameReader are exported so you can frame and unframe without a socket. The reader is a stateful decoder: feed it whatever TCP hands you, and complete frames fire on onFrame.

import { encodeFrame, FrameReader } from "@cosyte/mllp";

const payload = Buffer.from(
  "MSH|^~\\&|SENDING_APP|SENDING_FAC|RECEIVING_APP|RECEIVING_FAC|20260101120000||ADT^A01|CTRL0001|P|2.5.1\r",
);

const frame = encodeFrame(payload);
console.log(frame.subarray(0, 3).toString("hex"), frame.subarray(-2).toString("hex"));
console.log("frame", frame.length, "payload", payload.length);

const reader = new FrameReader({
  onFrame: (bytes, byteOffset, warnings) => {
    console.log("onFrame at", byteOffset, bytes.length, "bytes,", warnings.length, "warnings");
  },
});

reader.push(frame.subarray(0, 10)); // partial chunk: nothing fires
reader.push(frame.subarray(10)); // completes the frame
0b4d53 1c0d
frame 105 payload 102
onFrame at 0 102 bytes, 0 warnings

0b is the leading VT, 1c0d the trailing FS CR, and the three framing bytes are the whole difference between 105 and 102.

The commit contract

A positive acknowledgment (AA) tells the sender "you may forget this message. I have it." So a receiver must never send one before the message is durably handled, or the message is silently lost. @cosyte/mllp makes that structural: pair autoAck: 'AA' with an onMessage handler and the server awaits your handler and only then acknowledges.

Handler resolves, you get AA, unless the inbound could not carry a correlatable positive acknowledgment (no readable MSH, an empty MSH-10, a batch or concatenated payload, or trailing bytes the framer discarded). In that case the commit still happened, but the acknowledgment is downgraded to AE and a nack event names the reason, because a positive acknowledgment the sender cannot match is one it will resend. Handler throws, you get AE, or AR via MllpAckError. autoAck: 'AA' without a handler is documented as a transport-accept: "received and framed", not "processed".

Transport security (MLLPS)

TLS is built on node:tls: no bundled TLS, no extra dependency. Certificate verification is on by default, the server binds 127.0.0.1 by default, and binding all interfaces needs an explicit allowWildcardBind: true.

import { createServer, createClient } from "@cosyte/mllp";

// Server: mutual TLS (IHE ATNA ITI-19)
const server = createServer({
  tls: { cert: certPem, key: keyPem, ca: clientCaPem, clientAuth: "MUST" },
});
await server.listen(2575, "127.0.0.1");

// Client: verify the peer, and present a certificate of our own
const client = createClient({
  host: "mllp.example.com",
  port: 2575,
  tls: { ca: caPem, cert: clientCertPem, key: clientKeyPem },
});

The TLS guide has the ClientAuth table, the TLS 1.2 floor (IHE ATNA ITI-19), the typed failure modes (tls-verify against tls-handshake), and the bind-safety details.

API

Four code subpaths ship, plus @cosyte/mllp/package.json:

  • @cosyte/mllp is the whole transport: createServer and createStarterServer, createClient and createStarterClient, encodeFrame and FrameReader, Connection, buildRawAck, the typed errors, and the differential harness (runDifferential).
  • @cosyte/mllp/testing is InMemoryTransport, a deterministic socket-free test double. Every test that can run over it should.
  • @cosyte/mllp/ack-from-hl7 builds acknowledgments from a parsed message, or from raw bytes it parses through the peer, and is the one place the optional @cosyte/hl7 peer is needed.
  • @cosyte/mllp/arbitraries is fast-check generators for property-testing code you build on the transport: payload, frame, chunked and stream for canonical bytes, messyStream for the framing quirks a lenient reader accepts, and hostileInput for streams a reader may refuse but must never hang or crash on. It needs the optional fast-check peer, and imports nothing from the library at run time.

What comes with them:

  • Strict framing (VT + payload + FS + CR), acknowledgment correlation, auto-reconnect with backoff, and backpressure.
  • A lenient decoder and a strict encoder (Postel's Law) with 11 stable warning codes carrying byte offsets. Tolerance is opt-in per flag; the server ships tolerant defaults.
  • An explicit 6-state connection machine (CONNECTING | CONNECTED | DRAINING | RECONNECTING | DISCONNECTED | CLOSED) with stateChange events, never socket flags.
  • AbortSignal on every awaitable and Symbol.asyncDispose on every closeable.
  • A drain on close() that tells a message which was never written apart from one whose fate is unknown, because only one of those is safe to resend.
  • Zero runtime dependencies. Node stdlib only.

The full reference is on docs.cosyte.com/mllp; see Documentation.

Compatibility

  • Node.js 22 or later, tested on 22, 24, and 26. engines.node is >=22.0.0.
  • HL7 v2 payloads are bytes here, and the exceptions are named. The transport does not parse HL7: it reads the MSH header its acknowledgment is built from, and the MSA of an inbound acknowledgment, each bounded at that segment's own terminator. The opt-in ack-from-hl7 subpath is the one place a whole message is parsed. PHI and safety sets all of that out in full. For actual parsing, pair this with @cosyte/hl7, which is exactly what that subpath does.
  • Framing checked against canonical frames for freely available engines: Mirth Connect from NextGen, and the Google Cloud Healthcare MLLP adapter. The frames are written from the specification, not captured from a running engine, and no live engine runs in our CI. Epic and Cerner are not part of that harness, and no claim about either is made here. runDifferential ships in the published artifact, so you can aim it at your own test or staging endpoint and get a per-exchange frame-parity and MSA-2 correlation report for the engine you actually integrate with.
  • Known gaps, stated rather than hidden. No batch acknowledgment, no MLLP Release 2, no persistent queue of unacknowledged messages, no clinical acceptance decision, and no PKI. The known limitations page is the full list. Read it before you depend on this.

Documentation

The full reference lives at docs.cosyte.com/mllp, built from this repository's docs-content/. Every page is listed here, in the order and the groups of the site's sidebar:

Support

Questions, bug reports, and interoperability findings start on the issue tracker, and so does any change large enough to want agreement on the approach before a pull request. The organization's support page covers questions that span our libraries.

Need an MLLP integration built, or hosted and run for you? See our integration services.

Security

Report a vulnerability privately, as the security policy describes, never in a public issue.

PHI and safety

An MLLP frame carries an HL7 v2 message, and an HL7 v2 message carries patient data. This package is the transport under that traffic, so what it does with those bytes is a safety property rather than a footnote.

What it does with your payload. It moves the buffer you hand it to the peer and hands the peer's bytes back to your handler. It holds a message in memory only while that message is in flight: from send() until its acknowledgment is correlated, or until close() drains it or reports it unresolved. On the receive side your handler is given a copy. A frame that arrived across reads was gathered in the decoder's internal accumulator, which is never zeroed: a frame of up to 4 KiB stays in that private buffer until a later frame overwrites it, for as long as that reader lives, and the buffer that held a larger one is dropped once the frame is delivered and left to the garbage collector. That is buffer reuse rather than storage, and no public API hands the buffer out, but it is not a scrub.

The reads the transport performs, and where they stop. The transport does not parse HL7, but it is not blind to the payload either, and the bound is worth more to you than the slogan. To answer a message it has to correlate the answer, so it locates the MSH segment and reads the header fields an acknowledgment is built from and answered with: MSH-10, echoed byte for byte into MSA-2, the sending and receiving application and facility, which swap sides in the reply, the declared delimiters, processing id and version, and the acknowledgment mode in MSH-15 and MSH-16. That scan is bounded at the MSH segment's own terminator, so it reaches nothing past the header: on the transport path PID, and every other segment carrying clinical content, is never read, never decoded, and never echoed. Reading an inbound acknowledgment adds one segment and no more, MSA, for its acknowledgment code and the control id it echoes back, decoded under the same bound at that segment's own terminator. Those two are the whole of it, in one place, shared by the client's correlator and both acknowledgment builders. Separately, the leading identifier of each segment is checked so that a batch envelope or a second MSH can be refused rather than falsely acknowledged; no field of those segments is read.

The exception, and it is opt-in: @cosyte/mllp/ack-from-hl7. That subpath is a parser front end rather than transport. Hand buildMllpAck or detectMode a payload and this package decodes the whole message, later segments included, and gives it to the optional @cosyte/hl7 peer, which resolves every field of every segment, PID among them. That decode is this package's own code and it ships in this package's tarball, so count it as yours rather than the peer's. Nothing reaches it by default: the client, the server and buildRawAck never import that subpath, and the peer is an optional dependency you install deliberately. If your data-flow boundary turns on later segments never being materialized as decoded text, that is the subpath to leave out.

What it does not do. Outside that subpath it does not parse the message, and outside the two bounded segment reads above it does not inspect it. It writes nothing to disk: there is no queue, no write-ahead log, no replay store, and no cache anywhere in this package. Library code calls no logger and no console method.

What its diagnostics can carry. Shape rather than content: no error, warning, event payload or stats object ever echoes a run of message content.

  • A framing error carries at most the single byte at the structural violation, plus that byte's offset.
  • A warning message carries structural facts: byte offsets, counts, and accumulated sizes. Two framing codes are the exception worth knowing about. MLLP_MISSING_LEADING_VT and MLLP_FS_WITHOUT_CR render the hex of the single byte found where a framing byte was expected, and on a stream that omits its leading VT that byte is the first byte of the unframed content. One byte, never a run, the same bound a framing error carries. If that is more than your threat model allows, log code and byteOffset rather than message.
  • The acknowledgment-correlation diagnostics are stricter. Their text comes from a frozen registry keyed on the code alone, with no value parameter anywhere in it, so a control id cannot reach one; they report byte lengths instead of bytes.
  • getStats() returns plain JSON-serializable counters, and the two security warnings raised through process.emitWarning carry their code and a fixed description. The wildcard-bind warning names the address you bound, which is your own configuration, never message content.

What you still own.

  • Encryption in transit. A plain MLLP connection is cleartext on the wire. TLS is built in, but you have to turn it on and supply the material.
  • Logging and retention. The moment you log a payload, an acknowledgment body, or a control id, it is yours to protect. Nothing here stores a message, so nothing here can delete one on request either.
  • Idempotency. MLLP plus an acknowledgment is at-least-once at best. De-duplicate on MSH-10 plus MSH-7 in your application.
  • Exposure. The 127.0.0.1 default and the wildcard-bind opt-in are guardrails, not a network policy. Widening either is your decision.

Every example on this page uses placeholder application and facility names and a synthetic control id. No sample here carries a name, a date of birth, an identifier, or an address, and none should.

Contributing

Pull requests are welcome. The organization's contributing guide and code of conduct apply here.

A contribution must clear every gate this repository ships, all of them scripts you can run yourself:

pnpm typecheck
pnpm lint
pnpm test
pnpm format:check
pnpm check:no-emdash
pnpm check:no-internal-refs
pnpm check:documents   # the README, CLAUDE.md and catalog line
pnpm phi-scan
pnpm build
pnpm changeset   # every meaningful change carries one

Never commit real patient data, in a test fixture or anywhere else. pnpm phi-scan runs on every commit and is deliberately hard to bypass.

Trademarks

Epic, Cerner, Mirth Connect, NextGen, and Google Cloud Healthcare are trademarks of their respective owners. Cosyte is not affiliated with, endorsed by, or sponsored by any of them. The names identify the engines this package is tested against, and those it is not. See TRADEMARKS.md.

License

MIT, Copyright (c) 2026 Cosyte LLC. See LICENSE.

版本与发布记录

@cosyte/mllp 共发布 26 个版本,首次发布于 2026/07/17;过去 12 个月发布了 26 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.1.14latest2026/11/121 天前
0.1.132026/11/121 天前
0.1.122026/11/122 天前
0.1.112026/11/113 天前
0.1.102026/11/113 天前
0.1.82026/11/113 天前

关于 @cosyte/mllp 的常见问题

@cosyte/mllp 是什么?

MLLP transport for HL7 v2 in Node.js and TypeScript: client and server, framing, ACK correlation, auto-reconnect, TLS, and an in-memory transport for tests.

如何安装 @cosyte/mllp?

在项目目录运行 npm install @cosyte/mllp,或使用 pnpm add / yarn add / bun add。

@cosyte/mllp 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@cosyte/mllp 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@cosyte/mllp 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@cosyte/mllp 可以商用吗?

@cosyte/mllp 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@cosyte/mllp 的下载量有多少?

根据 npm 官方下载统计,@cosyte/mllp 在 2026/10/11 – 2026/11/09 这 30 天内被下载 2,102 次,日均约 70 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@cosyte/mllp 还在维护吗?

最新版本 0.1.14 发布于 2026/11/12(1 天前);至今共发布 26 个版本,其中过去 12 个月发布了 26 个;首次发布于 2026/07/17。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。