npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@glion/mllp-client

v0.19.0

Published

Persistent, single-flight MLLP client for HL7v2

Readme

@glion/mllp-client

A simple HL7v2 MLLP client for Node.js and Cloudflare Workers.

  • 📦 MLLP built in. Framing, message boundaries and acknowledgment matching are handled. You send a parsed message and get one back.
  • 🔄 Predictable connection lifecycle. Timeouts on connecting and on waiting for a reply, connection attempts retried with backoff, TCP keepalive by default, and explicit states you can read.
  • Thin over TCP. One socket, one message at a time. No queue, no worker threads, no polling.
  • 🧯 Errors you can act on. Every failure carries a stable code and says whether the connection is still usable.
  • 🧩 Any transport. TCP included. TLS, Cloudflare Workers or an in-memory socket plug in behind it.
  • 🔤 Typed end to end. TypeScript throughout, with parsed HL7v2 going in and coming out.

Coming soon — TLS and Cloudflare Workers adapters. Today the only bundled adapter is Node.js over TCP.

Install

npm install @glion/mllp-client

Node.js 22 or later. ESM only.

Use

Send a message

import { parseHL7v2 } from "@glion/parser";
import { MllpClient } from "@glion/mllp-client";
import { nodeSocket } from "@glion/mllp-client/node";

await using client = new MllpClient({
  socket: nodeSocket({ host: "hl7.example.org", port: 2575 }),
});

const adt = parseHL7v2(
  [
    "MSH|^~\\&|SENDER|FAC|RECV|FAC|20260101120000||ADT^A01|MSG00001|P|2.5",
    "EVN|A01|20260101120000",
    "PID|1||12345^^^MRN||DOE^JOHN||19800101|M",
  ].join("\r")
);

const ack = await client.send(adt);
ack.code; //=> "AA" or "CA"
ack.raw; //=> the acknowledgment as text
ack.tree; //=> the acknowledgment as a tree

send() connects on first use, so connect() is optional. await using closes the client at the end of the block; without it, call close().

Handle a rejection

A NAK means the receiver read your message and refused it — a bad field, a patient it does not know, a message type it does not handle. send() throws, and the connection stays open for the next message.

import { AckException } from "@glion/ack";
import { MllpClientError } from "@glion/mllp-client";

try {
  await client.send(adt);
} catch (error) {
  if (error instanceof AckException) {
    error.code; //=> "AE", "AR", "CE", or "CR"
    error.text; //=> the receiver's reason, when it gave one
  } else if (error instanceof MllpClientError) {
    error.code; //=> e.g. "SEND_TIMEOUT" — see Errors
  }
}

Connect at startup

connect() opens the connection without sending anything. Call it at startup to find out immediately that a host, port, or firewall is wrong, instead of on your first real message.

const client = new MllpClient({
  socket: nodeSocket({ host: "hl7.example.org", port: 2575 }),
});
await client.connect();

Calling it twice is safe. On a connected client it returns immediately; while a connection is still opening, it waits for that attempt and shares its result.

Reconnect

A connection that cannot be opened is dialed again. By default the client makes five further attempts, waiting a random time before each: up to 1 s, then 2 s, 4 s, 8 s, and 16 s, so it gives up about 30 seconds after the first failure and closes with the last attempt's error. attempts: Infinity keeps dialing; close() stops it. A send() that arrives while the client is connecting waits for the outcome.

A connection that is lost is not restored. The client closes with the failure, and the message in flight is not sent again: its send() rejects, and whether the receiver got it is the caller's to decide. A client is as disposable as a socket: construct a new one to send again.

const client = new MllpClient({
  socket: nodeSocket({ host: "hl7.example.org", port: 2575 }),
  reconnect: {
    attempts: 20,
    delay: (attempt) => {
      logger.warn({ attempt }, "mllp reconnecting");
      return Math.min(30_000, 500 * 2 ** attempt);
    },
  },
});

reconnect: false turns it off: the client dials once.

Options

| Option | Type | Default | Description | | ------------------ | ------------------------------- | --------- | ------------------------------------------------------------------------------------------------------ | | socket | MllpSocket | required | Runtime adapter, such as nodeSocket({ host, port }). | | connectTimeoutMs | number | 10000 | Time allowed to open the connection. Exceeded: MllpConnectionTimeoutError. | | sendTimeoutMs | number | 30000 | Time allowed from writing a message to receiving its acknowledgment. Exceeded: MllpSendTimeoutError. | | maxBufferedBytes | number | 16 MiB | Largest reply the client buffers. Exceeded: MllpInvalidResponseError. | | reconnect | MllpReconnectOptions \| false | see below | How the client dials again after a failed attempt. false dials once. |

send(message, { timeoutMs }) overrides sendTimeoutMs for one message.

MllpReconnectOptions:

| Option | Type | Default | Description | | ---------- | ----------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------- | | attempts | number | 5 | Attempts after a failed one before the client closes. Infinity for no limit. | | delay | (attempt: number) => number | full-jitter backoff from 1 s, capped at 30 s | Milliseconds to wait before attempt (from 1). Called only while attempts remain. |

API

new MllpClient(options)

new MllpClient(options: MllpClientOptions): MllpClient

Creates a client for one remote system. Nothing is opened until the first connect() or send().

Parameters — see Options.

Throws MllpInvalidOptionError when a timeout is not a positive number of milliseconds, maxBufferedBytes is not a positive integer, or reconnect.attempts is not a non-negative integer.

const client = new MllpClient({
  socket: nodeSocket({ host: "hl7.example.org", port: 2575 }),
  sendTimeoutMs: 10_000,
});

client.send(message, options?)

send(message: Root, options?: { timeoutMs?: number }): Promise<MllpClientResponse>

Sends one message and resolves with the acknowledgment that answers it. Connects first if the client is not connected.

| Parameter | Type | Description | | ------------------- | -------- | -------------------------------------------------------- | | message | Root | A parsed HL7v2 message. Must carry an MSH-10 control ID. | | options.timeoutMs | number | Overrides sendTimeoutMs for this message only. |

Returns an MllpClientResponse:

| Field | Type | Description | | ----------- | ---------------- | --------------------------------------------------- | | code | AckSuccessCode | MSA-1, AA or CA. | | controlId | string | MSA-2: the control ID of the message this answers. | | id | string | The acknowledgment's own MSH-10. | | text | string? | MSA-3, the receiver's diagnostic, when it gave one. | | tree | Root | The acknowledgment, parsed. | | raw | string | The acknowledgment as text. |

Throws

  • AckException — the receiver refused the message. The connection stays open.
  • MllpInvalidMessageError — no MSH-10, or the message could not be serialized. Nothing was sent.
  • MllpInvalidOptionErrortimeoutMs is out of range. Nothing was sent.
  • MllpAlreadySendingError — another send is in flight.
  • MllpClientClosedError — the client is closed.
  • MllpConnectionFailedError, MllpConnectionTimeoutError — the connection could not be opened.
  • MllpSendTimeoutError, MllpConnectionLostError, MllpInvalidResponseError — the exchange failed. These close the connection; see Errors.

Every error carries delivery, not-sent or unknown, the one fact a retry needs; see Errors.

One message at a time. To send several, await each in turn:

for (const message of batch) {
  const ack = await client.send(message);
  record(ack.controlId, ack.code);
}

client.connect()

connect(): Promise<void>

Opens the connection without sending anything, dialing again under the reconnect policy when an attempt fails. Optional — send() connects on first use — but calling it at startup surfaces a wrong host, port, or firewall rule as soon as the policy gives up.

Idempotent. On a connected client it resolves at once; while an attempt is in flight it waits for the outcome and shares it.

Throws the last attempt's MllpConnectionFailedError or MllpConnectionTimeoutError, or MllpClientClosedError when the client is already closed or close() cancelled the attempt.

client.close()

close(): Promise<void>

Closes the connection once the message in flight has been acknowledged. New sends are refused from the moment it is called, so an in-flight message is never cut off. An attempt to connect stops at once.

Resolves when the connection is down, from any phase. Never throws. Idempotent. The wait is bounded by the in-flight send's own deadline.

process.on("SIGTERM", async () => {
  await client.close();
  process.exit(0);
});

client.destroy()

destroy(): Promise<void>

Closes the connection now, without waiting for anything in flight. A message in flight rejects with MllpSendAbortedError. An attempt to connect stops at once.

Resolves when the connection is down, from any phase. Never throws. Idempotent.

client[Symbol.asyncDispose]()

Calls close(). Lets a client be scoped with await using:

{
  await using client = new MllpClient({ socket });
  await client.send(message);
} // closed here, even if send() threw

client.state

readonly state: MllpClientState

| Value | Meaning | | ------------ | --------------------------------------------------------------------------- | | idle | Nothing opened yet. The first send() or connect() opens the connection. | | connecting | The connection is being opened, further attempts included. | | connected | Open, with no message in flight. | | sending | A message is on the wire, waiting for its acknowledgment. | | closing | close() is waiting out the message in flight. | | closed | Done. |

A client closes once. After close(), destroy(), a connection the reconnect policy could not open, or a connection that was lost, every call throws MllpClientClosedError; construct a new client to send again.

client.connected

readonly connected: boolean

true in connected and sending, false everywhere else.

client.on(event, listener) / client.off(event, listener)

on<E>(event: E, listener: MllpClientListener<E>): this
off<E>(event: E, listener: MllpClientListener<E>): this

Adds or removes a listener. Both return the client, so calls chain.

| Event | Listener | Fires when | | --------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | connect | () => void | The connection opened. | | close | (error: MllpClientError \| null) => void | The client is done. Fires once, from any phase, even if it never connected. error is the failure it could not recover from, or null when you closed it. |

A lost connection is not an event of its own: it closes the client, so close fires with the failure. Listeners are synchronous, and one that throws propagates to whatever triggered the event.

client
  .on("connect", () => metrics.increment("mllp.connected"))
  .on("close", (error) => {
    logger.warn({ code: error?.code ?? "closed_by_owner" }, "mllp closed");
  });

Runtimes

The client speaks MLLP over a pair of byte streams and knows nothing else about the transport. That whole dependency is MllpSocket — two methods — so supporting a new runtime means writing an adapter, not forking the client.

Node.js is the only adapter that ships today.

Node.js

import { nodeSocket } from "@glion/mllp-client/node";

nodeSocket(options: NodeSocketOptions): MllpSocket

Plain TCP over net.Socket.

| Option | Type | Default | Description | | ----------------- | -------- | -------- | ----------------------------------------------------------------------------------------------------------- | | host | string | required | Host name or address of the receiver. | | port | number | required | TCP port of the receiver. | | gracefulCloseMs | number | 1000 | How long a socket gets to end cleanly before it is destroyed. | | keepAliveIdleMs | number | 30000 | Idle time before the first keepalive probe, so a silent NAT or firewall drop surfaces before the next send. |

TCP_NODELAY is set, so a message goes out immediately rather than waiting on Nagle's algorithm.

const client = new MllpClient({
  socket: nodeSocket({ host: "hl7.example.org", port: 2575 }),
});

Custom Socket

You can expand MllpSocket to build a custom socket to one remote system, which the client opens, uses, and ends.

interface MllpSocket {
  connect(signal: AbortSignal): Promise<MllpStreams>;
  close(): Promise<void>;
}

interface MllpStreams {
  readonly readable: ReadableStream<Uint8Array>;
  readonly writable: WritableStream<Uint8Array>;
}

An implementation must satisfy four rules:

  1. connect() rejects with signal.reason when the signal aborts, and a rejection leaves nothing open.
  2. close() never rejects, may be called more than once, and always finishes within a bounded time, even when the receiver never answers.
  3. When the socket ends, a pending read on readable ends or errors. Bytes sent before a clean close arrive first.
  4. The client owns the streams while connected, and releases them before calling close().

An implementation never sees an MLLP frame — framing belongs to the layer above.

Errors

Every failure the client raises extends MllpClientError and carries three fixed things: a code, one word per class for a switch or a log field; a delivery, what became of the message; and a message that never changes. Errors from the layers below arrive on cause, never as the thrown type.

A rejection from the receiver is not an MllpClientError — see AckException.

delivery is the one fact a retry decision needs:

| delivery | Meaning | Codes | | ---------- | --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | | not-sent | Nothing reached the wire. The message may be sent again as it is. | INVALID_OPTION, INVALID_MESSAGE, ALREADY_SENDING, CLOSED, CONNECTION_FAILED, CONNECTION_TIMEOUT | | unknown | The message may have reached the receiver. Sending it again may deliver it twice. | SEND_TIMEOUT, CONNECTION_LOST, SEND_ABORTED, INVALID_RESPONSE |

import { MllpClientError, MllpErrorCode } from "@glion/mllp-client";

try {
  await client.send(message);
} catch (error) {
  if (error instanceof MllpClientError) {
    if (error.delivery === "not-sent") {
      return requeue(message); // safe to send again
    }
    switch (error.code) {
      case MllpErrorCode.INVALID_RESPONSE:
        return quarantine(message); // the receiver answered, unreadably
      default:
        return holdForReview(message); // it may already have been processed
    }
  }
}

The four failures of the wire — CONNECTION_FAILED, CONNECTION_TIMEOUT, SEND_TIMEOUT, CONNECTION_LOST — also extend MllpConnectionError, one instanceof for "the link, not the message".

INVALID_OPTION

MllpInvalidOptionError · delivery not-sent

A constructor option or a per-send timeoutMs is out of range — a timeout that is not a positive number of milliseconds, a maxBufferedBytes that is not a positive integer, or a reconnect.attempts that is not a non-negative integer or Infinity. The message names which one.

Thrown before anything is opened or sent. A configuration bug, not a runtime condition.

INVALID_MESSAGE

MllpInvalidMessageError · delivery not-sent · field: cause

The message cannot be sent as it stands: no MSH-10 control ID, or it could not be serialized, or its content contains a byte MLLP reserves as a frame marker.

Nothing reached the wire and the connection is still in step, so the next message can go out on it. Fix or quarantine the message; do not reconnect.

ALREADY_SENDING

MllpAlreadySendingError · delivery not-sent · field: controlId

send() was called while another send was in flight. controlId identifies the message already on the wire.

The client is lockstep by design. Await each send before starting the next, or give each concurrent stream its own client.

CLOSED

MllpClientClosedError · delivery not-sent · field: cause

The client is closed, so the call cannot be served. Also the error a connect() gets when close() cancelled the attempt it was waiting for.

A client closes once. Construct a new one to send again. When the client closed because the reconnect policy gave up, cause is the last attempt's failure.

CONNECTION_FAILED

MllpConnectionFailedError · delivery not-sent · field: cause

The socket could not be opened. cause carries the underlying error — ECONNREFUSED, ENOTFOUND, EHOSTUNREACH and the like.

Check host, port, and whether a firewall allows the route. Nothing was opened, so there is nothing to close.

CONNECTION_TIMEOUT

MllpConnectionTimeoutError · delivery not-sent · field: timeoutMs

The receiver did not accept the connection within connectTimeoutMs.

Typically a packet-dropping firewall rather than a refused connection — a refusal arrives fast and surfaces as CONNECTION_FAILED.

SEND_TIMEOUT

MllpSendTimeoutError · delivery unknown · field: timeoutMscloses the connection

No acknowledgment arrived within the send timeout.

Whether the receiver got the message is unknown: it may be slow, or it may have processed the message and failed to reply. The connection closes because a late acknowledgment can no longer be told apart from the next message's — see Why does a failed send close the connection?. The client closes with this error; the message is not sent again.

CONNECTION_LOST

MllpConnectionLostError · delivery unknown · field: causecloses the connection

The link went away mid-send: the receiver hung up, or the network broke. cause carries the stream error when there was one.

Whether the message was received is unknown. The client closes with this error; the message is not sent again.

SEND_ABORTED

MllpSendAbortedError · delivery unknown

destroy() cut off the message in flight. close() never raises this: it waits the message out.

INVALID_RESPONSE

MllpInvalidResponseError · delivery unknown · fields: controlId?, causecloses the connection

The reply was not a usable acknowledgment of the message that was waiting. Causes, in the order they are checked:

  • the bytes were not valid UTF-8 or not parseable HL7v2;
  • MSA-2 names a different message — usually a late acknowledgment from an earlier timed-out send;
  • MSA-1 is empty or is not one of the six codes of Table 0008;
  • the reply passed maxBufferedBytes before it was complete;
  • the connection closed partway through the reply.

The connection closes because the client can no longer tell which reply answers which message. The client closes with this error.

AckException

From @glion/ack · fields: code, controlId, text, errorCode, severity

Not an MllpClientError. The receiver read the message and refused it, which is an answer rather than a fault — the connection stays open and the next message can go out on it.

| Field | Source | Description | | ----------- | ------ | --------------------------------------------- | | code | MSA-1 | AE, AR, CE, or CR. | | controlId | MSA-2 | The message being refused. | | text | MSA-3 | The receiver's reason, when it gave one. | | errorCode | ERR-3 | HL7v2 Table 0357 error condition, when given. | | severity | ERR-4 | E, W, or I, when given. |

The class is the MSA-1 code: AckApplicationError (AE), AckApplicationReject (AR), AckCommitError (CE), AckCommitReject (CR). An MLLP server built on Glion raises the same types, so both ends of an integration catch the same thing.

FAQs

Why does a failed send close the connection?

MLLP has no correlation of its own. The only thing tying a reply to a message is the receiver echoing your MSH-10 back in MSA-2 — and the only reason that can be trusted is that one message is outstanding at a time.

Once a send times out, that no longer holds. A wrong guess here reports one message's outcome under another message's identity, which in a clinical feed is worse than an error.

Clients that pipeline can survive a timeout, because they keep a table of outstanding control IDs and a background reader to match against it. A lockstep client has no table to fall back on.

The client closes with the failure, and never sends the failed message again — only you know whether the receiver already has it. Construct a new client to go on, as you would open a new socket.

Why a socket rather than a host and port?

The client handles MLLP and deliberately handles no transport. Putting the whole runtime dependency behind two methods means the same client runs over TCP, TLS, a Unix socket, or a pair of in-memory streams — and the test suite covers every lifecycle path without opening a port.

Part of Glion

@glion/mllp-client is part of Glion, the application framework for HL7v2. See the Glion README for the full package catalog and architecture.