Skip to main content
Version: v0.0.10

@cosyte/mllp

@cosyte/mllp, Production-grade MLLP client and server for Node.js.

Transport-only sibling to @cosyte/hl7. Handles framing, ACKs, reconnects, backpressure, and TLS without requiring knowledge of the MLLP spec.

Example​

import { createStarterServer } from '@cosyte/mllp';
const server = await createStarterServer({ port: 2575, onMessage: (buf) => buf });

Classes​

Connection​

A single MLLP connection wrapping a Transport with a 6-state FSM, lifecycle events, per-connection warning streams, and getStats() observability.

Emits events: stateChange, connect, disconnect, reconnecting, close, message, warning, error.

Example​

import { Connection, NetTransport } from '@cosyte/mllp';
import { createConnection } from 'node:net';

const socket = createConnection({ host: 'localhost', port: 2575 });
const conn = new Connection({ transport: new NetTransport(socket) });
conn.on('stateChange', ({ from, to }) => console.log(from, '->', to));
conn.on('message', ({ payload }) => console.log('received', payload.length, 'bytes'));
socket.on('connect', () =>
conn.notifyConnect(socket.remoteAddress ?? null, socket.remotePort ?? null)
);

Extends​

  • EventEmitter

Constructors​

Constructor​

new Connection(opts): Connection

Construct a connection over the given transport. Starts in CONNECTING; call Connection.notifyConnect once the transport handshake completes.

Parameters​
opts​

ConnectionOptions

Connection options (the transport, message/warning handlers, drain timeout, framing).

Returns​

Connection

Overrides​

EventEmitter.constructor

Properties​

beforeClose​

beforeClose: (drainTimeoutMs) => Promise<void>

beforeClose hook, no-op default that resolves immediately.

An owner may replace this instance property to run work before the socket closes. Connection calls it during close(), racing against drainTimeoutMs.

Parameters​
drainTimeoutMs​

number

Returns​

Promise<void>

connectionId​

readonly connectionId: string

Stable UUIDv4 identifier for this connection.

Accessors​

state​
Get Signature​

get state(): ConnectionState

Current FSM state. One of the 6 ConnectionState values.

Subscribe to 'stateChange' events for reactive state monitoring.

Returns​

ConnectionState

Methods​

close()​

close(opts?): Promise<void>

Initiate graceful close of the connection.

  • If CONNECTING or RECONNECTING: cancels the pending attempt, transitions directly to CLOSED, and calls transport.destroy().
  • If CONNECTED: transitions to DRAINING, calls beforeClose(drainTimeoutMs), then DRAINING → DISCONNECTED once the hook resolves, or DRAINING → CLOSED if the drain timeout elapses first.
  • If DRAINING: waits for the existing drain to complete (idempotent).
  • If CLOSED or DISCONNECTED: no-op.
Parameters​
opts?​
drainTimeoutMs?​

number

Override drain timeout (default: opts.drainTimeoutMs ?? 30_000).

Returns​

Promise<void>

Example​
await conn.close({ drainTimeoutMs: 5_000 });
destroy()​

destroy(reason?): void

Abruptly destroy the connection, discarding any pending writes.

Transitions from any non-terminal state directly to CLOSED and calls transport.destroy(reason). Emits 'close' event. Idempotent, safe to call multiple times.

Parameters​
reason?​

Error

Optional error to propagate to the transport.

Returns​

void

Example​
conn.destroy(new Error('Timeout exceeded'));
getStats()​

getStats(): ConnectionStats

Return a JSON-serializable observability snapshot.

All timestamp fields are Date | null (not epoch milliseconds). JSON.stringify() serialises them to ISO 8601 strings with no information loss.

warningsByCode reflects every warning ever received regardless of ring buffer truncation. warningsTruncated is true if the 100-entry ring buffer has overflowed.

Returns​

ConnectionStats

Example​
const stats = conn.getStats();
console.log(JSON.stringify(stats)); // log-pipeline friendly
notifyConnect()​

notifyConnect(remoteAddress, remotePort): void

Notify the connection that the transport handshake has completed.

Transitions CONNECTING → CONNECTED and emits the 'connect' event. Called externally by Server/Client after the socket connects.

Parameters​
remoteAddress​

string | null

Remote peer IP address, or null if unavailable.

remotePort​

number | null

Remote peer port, or null if unavailable.

Returns​

void

Example​
socket.on('connect', () =>
conn.notifyConnect(socket.remoteAddress ?? null, socket.remotePort ?? null)
);
onWarning()​

onWarning(fn): void

Register or replace the per-connection warning subscriber.

Subsequent calls replace the previous handler (set-once semantics prevent leaks). The handler is called synchronously and any exception it throws is swallowed.

Parameters​
fn​

(w) => void

Returns​

void

Example​
conn.onWarning((w) => logger.warn({ code: w.code, connectionId: w.connectionId }));
send()​

send(data): boolean

Write raw bytes to the transport (no framing applied).

The server and the client wrap this with encodeFrame() to add MLLP framing. Returns false if the connection is CLOSED or DISCONNECTED (no bytes written).

Parameters​
data​

Buffer

Returns​

boolean

true if bytes flushed immediately; false if buffered (backpressure) or not writable.

Example​
const flushed = conn.send(encodeFrame(payload));
if (!flushed) logger.warn('backpressure detected');

FrameReader​

Stateful MLLP frame decoder. Feed raw TCP byte chunks via push(chunk). Complete frames fire synchronously via onFrame callback during push().

The decoder operates as a 3-state FSM:

  • SCANNING_FOR_VT, waiting for the 0x0B frame-start byte
  • READING_PAYLOAD, accumulating payload bytes until FS (0x1C)
  • EXPECTING_CR, received FS, waiting for CR (0x0D) to complete the frame

Example​

const reader = new FrameReader({
onFrame: (payload, byteOffset, warnings) => process(payload, byteOffset, warnings),
onWarning: (w) => logger.warn(w),
});
socket.on('data', (chunk) => reader.push(chunk));

Constructors​

Constructor​

new FrameReader(opts): FrameReader

Construct a chunked MLLP frame reader.

Parameters​
opts​

FrameReaderOptions

Reader options (onFrame/onWarning callbacks, tolerance, maxFrameSizeBytes).

Returns​

FrameReader

Methods​

push()​

push(chunk): void

Feed a chunk of raw TCP bytes into the FSM. Frames fire synchronously via onFrame during this call. May throw MllpFramingError for unrecoverable framing violations when the matching tolerance is not enabled.

Parameters​
chunk​

Buffer

Returns​

void

Example​
socket.on('data', (chunk) => reader.push(chunk));
reset()​

reset(): void

Clear internal accumulator state and reset byte offset to 0.

Call on reconnect / connection reuse to start fresh without allocating a new reader. Pending partial frame state is discarded silently.

Returns​

void

Example​
socket.on('close', () => reader.reset());

MllpAckError​

Error a message handler throws to control the negative acknowledgement the server returns. Throwing any error from a commit-gated handler yields AE by default; throw an MllpAckError to choose AR (application reject) instead.

The message is never copied into the ACK bytes or any emitted event, it may carry PHI. Only the static, non-PHI ackCode influences the wire response.

Example​

import { createServer, MllpAckError } from '@cosyte/mllp';

createServer({
autoAck: 'AA',
onMessage: async (payload) => {
if (!isAcceptable(payload)) {
// sender should NOT resend unchanged -> AR
throw new MllpAckError('unsupported message type', { ackCode: 'AR' });
}
await db.commit(payload); // throw here -> AE (resend may succeed)
},
});

Extends​

  • Error

Constructors​

Constructor​

new MllpAckError(message, opts?): MllpAckError

Parameters​
message​

string

Diagnostic text for the thrower. Never placed on the wire (may carry PHI).

opts?​
ackCode?​

NegativeAckCode

Negative code to return. Default 'AE'.

cause?​

unknown

Underlying error, preserved on .cause.

Returns​

MllpAckError

Overrides​

Error.constructor

Properties​

ackCode​

readonly ackCode: NegativeAckCode

The negative acknowledgement code to return (AE or AR).


MllpBackpressureError​

Thrown (or rejects the send() promise) when the in-flight queue exceeds the configured high-water mark and onBackpressure: 'reject' is set.

highWaterMark accepts a count cap, a byte cap, or both, when both are present, the stricter-of-two trigger wins.

Example​

try {
await client.send(payload);
} catch (err) {
if (err instanceof MllpBackpressureError) {
logger.warn({
queueDepth: err.queueDepth,
queueBytes: err.queueBytes,
cap: err.highWaterMark,
});
}
}

Extends​

  • Error

Constructors​

Constructor​

new MllpBackpressureError(message, opts): MllpBackpressureError

Construct an MLLP backpressure error.

Parameters​
message​

string

Human-readable error message.

opts​

Backpressure context (queue depth, queued bytes, the high-water-mark hit).

highWaterMark​

{ bytes?: number; count?: number; }

highWaterMark.bytes?​

number

highWaterMark.count?​

number

queueBytes​

number

queueDepth​

number

Returns​

MllpBackpressureError

Overrides​

Error.constructor

Properties​

highWaterMark​

readonly highWaterMark: object

The high-water-mark configuration that was triggered.

bytes?​

readonly optional bytes?: number

count?​

readonly optional count?: number

name​

readonly name: "MllpBackpressureError"

Overrides​

Error.name

queueBytes​

readonly queueBytes: number

Total bytes of in-flight + queued frames at the moment of rejection.

queueDepth​

readonly queueDepth: number

Number of in-flight + queued sends at the moment of rejection.


MllpClient​

MLLP client, composes a single Connection over a NetTransport (production) or any other Transport (testing via InMemoryTransport).

Public events, every payload Object.freeze'd before emission:

  • 'stateChange', { from, to, reason? } from the underlying Connection FSM
  • 'connect', { connectionId } once the FSM enters CONNECTED
  • 'disconnect', { connectionId } once the FSM enters DISCONNECTED
  • 'reconnecting', { connectionId, attempt?, delayMs? }
  • 'close', { connectionId } once the FSM enters terminal CLOSED
  • 'message', { payload, connectionId, byteOffset, warnings } for every inbound frame
  • 'warning', MllpWarning | AckCorrelationWarning. A framing deviation arrives as a MllpWarning enriched with connectionId from the Connection layer; the two ACK-correlation codes arrive as AckCorrelationWarning, which adds controlIdBytes and elapsedSinceSendMs. Narrow on code before reading either.
  • 'securityWarning', SecurityWarning. Emitted on every successful secureConnect (initial + every reconnect) when tls.allowUnverified is true with code MLLP_TLS_VERIFY_DISABLED. Also mirrored to process.emitWarning.
  • 'error', re-emitted from Connection. Guarded by listenerCount('error') > 0 so absence of a listener does NOT crash the process (server precedent).

Example​

const client = createClient({ host: 'localhost', port: 2575 });
client.on('stateChange', ({ from, to }) => logger.info({ from, to }));
client.on('message', ({ payload }) => logger.info({ bytes: payload.length }));
await client.connect();
const ack = await client.send(payloadBuffer);
await client.close();

Extends​

  • EventEmitter

Constructors​

Constructor​

new MllpClient(opts): MllpClient

Construct an MLLP client. Created idle; call connect() (or use createClient/createStarterClient) to open the connection.

Parameters​
opts​

ClientOptions

Client options (host/port, ACK timeout, reconnect/backpressure policy, …).

Returns​

MllpClient

Overrides​

EventEmitter.constructor

Accessors​

state​
Get Signature​

get state(): ConnectionState

Current FSM state. Mirrors the underlying Connection's state once attached; before connect() (or after a CLOSED Connection is dropped) reports the client-level baseline ('DISCONNECTED').

Returns​

ConnectionState

Methods​

_attachExistingConnection()​

_attachExistingConnection(conn): void

Internal

Test seam, attach an externally-built Connection directly, bypassing the net.createConnection + NetTransport path. Used by lifecycle tests driving InMemoryTransport.pair() for determinism.

Parameters​
conn​

Connection

Returns​

void

_captureConnectSignal()​

_captureConnectSignal(signal): void

Internal

Test seam, capture or rebind the connect-signal mid-flight.

Parameters​
signal​

AbortSignal

Returns​

void

_setReconnectFactory()​

_setReconnectFactory(factory): void

Internal

Test seam, install a factory that produces the next reconnect Connection.

Parameters​
factory​

() => object

Returns​

void

[asyncDispose]()​

[asyncDispose](): Promise<void>

Async disposal, delegates to MllpClient.close for await using support.

Returns​

Promise<void>

Example​
await using client = createClient({ host: 'localhost', port: 2575 });
await client.connect();
// client.close() is called automatically at end of block
close()​

close(opts?): Promise<void>

Gracefully close the client.

Delegates to Connection.close, which transitions CONNECTED → DRAINING → DISCONNECTED (or CLOSED on drain timeout). No-op if no Connection is attached.

Rejects with DOMException('Aborted', 'AbortError') if signal aborts mid-drain; on abort, the underlying Connection is force-destroyed.

Parameters​
opts?​
drainTimeoutMs?​

number

signal?​

AbortSignal

Returns​

Promise<void>

Example​
await client.close({ drainTimeoutMs: 5_000 });
connect()​

connect(opts?): Promise<void>

Open a TCP (or TLS) connection to the configured host:port and attach a Connection to it. Resolves once the FSM enters CONNECTED, for TLS, on 'secureConnect' (handshake complete, including certificate verification when it is on).

Rejects with:

  • DOMException('Aborted', 'AbortError') if signal is provided and aborts before the connect resolves.
  • MllpConnectionError({ phase: 'connect' }) if the underlying socket emits error before connecting, OR if the client is already connecting/connected. TLS failures carry a connectionCause: 'tls-verify' for certificate verification failures, 'tls-handshake' for TLS-protocol-shaped failures (isTlsProtocolError); pure TCP failures carry none.

Dual failure signal on initial connect: the Connection's transport error handler is attached before this promise's own error listener, so a pre-connect socket error produces BOTH the promise rejection AND a client 'error' event (when an 'error' listener is attached). Handle whichever fits your flow; they describe the same underlying failure.

TLS 1.3 + mutual TLS caveat (RFC 8446 §4.4.2): connect() resolving does NOT guarantee that a clientAuth: 'MUST' server accepted your client certificate. Under TLS 1.3 the client's handshake, and its 'secureConnect', can complete before the server finishes validating the certificate; a rejection then surfaces moments later as a typed post-connect error ('error' event with an ERR_SSL_*/alert cause, classified permanent, no auto-reconnect loop). ACK correlation via MllpClient.send remains the delivery guarantee: no send resolves without its ACK, so a rejected session can never silently "deliver".

Parameters​
opts?​
signal?​

AbortSignal

Returns​

Promise<void>

Example​
const ac = new AbortController();
setTimeout(() => ac.abort(), 5_000);
await client.connect({ signal: ac.signal });
destroy()​

destroy(reason?): void

Abruptly destroy the client, force-transitions the underlying Connection to CLOSED immediately. No-op if no Connection is attached. Idempotent.

Parameters​
reason?​

Error

Returns​

void

Example​
client.destroy(new Error('shutting down'));
getStats()​

getStats(): ClientStats

Returns a JSON-serializable observability snapshot.

All fields are plain values, no Buffers, no class instances, no Maps, no circular refs. Safe to JSON.stringify directly.

inFlight is the count of correlator entries with sentAt !== null (entries actually written to the wire and awaiting ACK), distinct from queueDepth which counts ALL live correlator entries (including pre-flush and serialization-queued sends).

Returns​

ClientStats

Example​
setInterval(() => logger.info(JSON.stringify(client.getStats())), 60_000);
send()​

send(payload, opts?): Promise<Buffer<ArrayBufferLike>>

Send an MLLP-framed payload and await the inbound ACK.

Resolves with the ACK Buffer (framing stripped). Rejects with:

  • DOMException('Aborted', 'AbortError') if signal aborts before the ACK.
  • MllpTimeoutError if no ACK arrives within ackTimeoutMs. The clock starts at the underlying write() flush callback, NOT at the send() call.
  • MllpConnectionError({ phase: 'send' }) if the client is not connected.

Emits a frozen 'ack' event on every successful match.

Parameters​
payload​

Buffer

Raw bytes; MLLP framing is added internally via encodeFrame.

opts?​
ackTimeoutMs?​

number

signal?​

AbortSignal

AbortSignal, aborting cancels the ACK wait.

Returns​

Promise<Buffer<ArrayBufferLike>>

Example​
const ack = await client.send(payloadBuffer);
logger.info({ ack: ack.toString('utf8') });

MllpConnectionError​

Thrown (or emitted via onError) for socket-layer problems such as connection refused, ECONNRESET, ETIMEDOUT, or DNS failure.

  • cause, the original OS or TLS error
  • phase, which connection lifecycle phase the failure occurred in
  • connectionCause, optional stable cause-code (e.g. for FIFO reconnect rejections)

Example​

throw new MllpConnectionError('Connection refused', {
cause: osError,
phase: 'connect',
});

Extends​

  • Error

Constructors​

Constructor​

new MllpConnectionError(message, opts): MllpConnectionError

Construct an MLLP connection error.

Parameters​
message​

string

Human-readable error message.

opts​

Error context (underlying cause, the lifecycle phase, optional stable cause code).

cause​

Error

connectionCause?​

ConnectionErrorCause

phase​

ConnectionErrorPhase

Returns​

MllpConnectionError

Overrides​

Error.constructor

Properties​

cause​

readonly cause: Error

The original OS or TLS error that caused this connection failure.

Overrides​

Error.cause

connectionCause?​

readonly optional connectionCause?: ConnectionErrorCause

Optional stable cause code. Present on FIFO reconnect rejections ('fifo-unsafe' for queued sends, 'in-flight-orphan' for in-flight sends).

The set of values is a public API; see ConnectionErrorCause.

name​

readonly name: "MllpConnectionError"

Overrides​

Error.name

phase​

readonly phase: ConnectionErrorPhase

Which connection lifecycle phase the failure occurred in.


MllpFramingError​

Thrown for unrecoverable MLLP wire-format violations.

  • code, stable WarningCode identifying the violation
  • byteOffset, absolute stream position where the violation was detected
  • snippet, the single framing-boundary byte that broke the structure, or empty when the anomaly is not a specific byte. Never a run of payload content (see the snippet PHI contract below); the constructor caps whatever it is given to 64 bytes as a backstop.

Example​

// Pass only the offending framing-boundary byte, never a slice of payload content.
throw new MllpFramingError('MLLP_MISSING_LEADING_VT', byteOffset, Buffer.from([byte]));

Extends​

  • Error

Constructors​

Constructor​

new MllpFramingError(code, byteOffset, snippet, message?): MllpFramingError

Construct an MLLP framing error.

Parameters​
code​

WarningCode

The stable warning code classifying the violation.

byteOffset​

number

Absolute stream byte offset where the violation was detected.

snippet​

Buffer

Raw bytes copied from around the anomaly (capped to 64 bytes).

message?​

string

Optional human-readable override message.

Returns​

MllpFramingError

Overrides​

Error.constructor

Properties​

byteOffset​

readonly byteOffset: number

Absolute byte offset in the stream where the error was detected.

code​

readonly code: WarningCode

Stable warning code identifying the violation type.

name​

readonly name: "MllpFramingError"

Overrides​

Error.name

snippet​

readonly snippet: Buffer

Up to 64 bytes copied from around the anomaly.

This is a copied Buffer, isolated from the source buffer so it remains valid after the underlying buffer is reused or overwritten.

PHI contract: the decoder only ever populates this with the single framing-boundary byte that violated the frame structure (whose hex value the message already discloses), never a run of payload content bytes. For anomalies whose fault is not a specific byte (MLLP_FRAME_TOO_LARGE, the accumulated size), the snippet is empty: a payload slice on a public error field would leak a field-body slice of clinical content. Callers constructing this directly are responsible for the same discipline.


MllpServer​

MLLP TCP server, wraps net.Server without extending it.

Each accepted socket is wrapped in a NetTransport, connected to a Connection with server-level framing options, and added to _connections. Messages are surfaced via the 'message' event and the onMessage callback.

Public events: 'listening', 'connection', 'message', 'nack', 'error', 'close'. The 'nack' event (NackEvent) fires whenever the server returns a negative ACK instead of AA on the auto-ACK path, a commit-gated autoAck: 'AA' handler failing, or a positive auto-ACK downgraded because the inbound could not be correlated (an unreadable/uncorrelatable message, or one whose bytes the decoder discarded). The NackReason distinguishes the causes.

'error' contract: underlying net.Server/tls.Server errors are forwarded to the 'error' event whenever a listener is attached. With no listener, the outcome depends on server state: during a listen() (the bind window) the error rejects the listen() promise the primary error surface, and the process never crashes on a bind error; with no listen() in flight and the server not serving (e.g. a stale async error after close()) the error is dropped; but while serving, an unlistened runtime error (e.g. accept-loop EMFILE) keeps Node's fail-loud crash-on-unlistened-'error' convention, a silent accept outage is impossible. Caveat for 'error' listeners: the forwarder runs before the internal listen() rejection handler, so an 'error' listener that synchronously calls close() during the bind window changes the listen() rejection from the bind error to the typed close-during-listen MllpConnectionError, match on the 'error' event payload, not the rejection, if you do that. TLS (MLLPS) adds two more: 'tlsClientError' (a failed TLS handshake, the server logs it and keeps serving other connections) and 'securityWarning' (loud, one-time notice when a wildcard host is bound via allowWildcardBind: true). All event payloads are Object.freeze()'d before emission.

Example​

import { createServer } from '@cosyte/mllp';

const server = createServer({
onMessage: (payload, meta, conn) => {
console.log('received from', meta.connectionId);
},
});
await server.listen(2575);
// await using server = createServer({ ... }); // Symbol.asyncDispose

Extends​

  • EventEmitter

Constructors​

Constructor​

new MllpServer(opts): MllpServer

Construct an MLLP server. Created idle; call listen() (or use createServer/createStarterServer) to begin accepting connections.

Parameters​
opts​

ServerOptions

Server options (bind host/port, auto-ACK policy, message handler, framing, …).

Returns​

MllpServer

Overrides​

EventEmitter.constructor

Methods​

[asyncDispose]()​

[asyncDispose](): Promise<void>

Async disposal, delegates to close() for await using support.

Returns​

Promise<void>

Example​
await using server = createServer({ onMessage: handler });
await server.listen(2575);
// server.close() is called automatically at end of block
close()​

close(opts?): Promise<void>

Stop accepting new connections and gracefully close all active connections.

Sequence:

  1. net.Server.close(), stops accepting new connections immediately
  2. If _connections is empty: resolves immediately (no drain needed)
  3. Calls _drainAll(drainTimeoutMs), Promise.all + side-effect setTimeout that force-destroys stragglers after the drain window

Calling close() while a listen() is still in flight proactively settles that listen() with a typed MllpConnectionError rejection and clears the single-flight guard, the server is immediately re-listenable. Symbol.asyncDispose (which delegates here) is therefore safe before listen() settles. Qualification: a close({ signal }) whose signal is already aborted rejects immediately with AbortError and performs no work, it does NOT close the server and does NOT settle the in-flight listen(), which simply continues and settles on its own bind outcome.

Parameters​
opts?​
drainTimeoutMs?​

number

Override drain timeout (default: opts.drainTimeoutMs ?? 30_000).

signal?​

AbortSignal

AbortSignal to cancel the close operation. On abort, all active connections are destroyed and the promise rejects with AbortError.

Returns​

Promise<void>

Example​
await server.close({ drainTimeoutMs: 5_000 });
getStats()​

getStats(): ServerStats

Return a JSON-serializable observability snapshot.

totalBytesIn and totalBytesOut aggregate from live connections at call time.

Returns​

ServerStats

Example​
const stats = server.getStats();
logger.info(JSON.stringify(stats));
listen()​

listen(port, hostOrOpts?): Promise<void>

Start listening on the given port.

Resolves once the TCP socket is bound and emits 'listening' with Object.freeze({ port: actualPort, host: actualHost }).

Single-flight: one listen() per server lifecycle at a time. A call while the server is already listening, or while another listen() is still in flight, rejects with a typed MllpConnectionError (concurrent binds raced each other's post-bind safety checks). Call close() before re-listening; sequential listen() → close() → listen() is fine.

close() during an in-flight listen() rejects that listen() with a typed MllpConnectionError (never a hang) and clears the single-flight guard, a subsequent listen() on the same server works. This makes Symbol.asyncDispose (which delegates to close()) safe even before a listen() has settled. (Exception: close({ signal }) with an already-aborted signal is a no-op AbortError rejection, the in-flight listen() is left to settle on its own bind outcome; see close().)

Parameters​
port​

number

TCP port to bind. Use 0 to let the OS assign an ephemeral port.

hostOrOpts?​

string | { host?: string; signal?: AbortSignal; }

Host string or object with host and optional signal. Default host: '127.0.0.1' (bind-safety hardening). Wildcard hosts are rejected unless ServerOptions.allowWildcardBind: true, literal spellings pre-bind, resolver-only shorthands post-bind via the OS-normalized bound address.

Returns​

Promise<void>

Example​
await server.listen(2575); // binds 127.0.0.1
await server.listen(0, '127.0.0.1');
await server.listen(0, { host: '127.0.0.1', signal: ac.signal });

MllpTimeoutError​

Thrown (or rejects the send() promise) when an ACK does not arrive within the configured ackTimeoutMs.

The timeout clock starts at the underlying write() flush callback, NOT at the send() call, pre-flush queue time is not charged to the peer.

Example​

try {
await client.send(payload);
} catch (err) {
if (err instanceof MllpTimeoutError) {
logger.warn({ elapsedMs: err.elapsedMs, idBytes: err.messageControlIdBytes });
}
}

Extends​

  • Error

Constructors​

Constructor​

new MllpTimeoutError(message, opts): MllpTimeoutError

Construct an MLLP timeout error.

Parameters​
message​

string

Human-readable error message. Structural facts only, never field content.

opts​

Timeout context (control-id byte length, elapsed time, flush timestamp).

elapsedMs​

number

messageControlIdBytes​

number | undefined

sentAt​

number

Returns​

MllpTimeoutError

Overrides​

Error.constructor

Properties​

elapsedMs​

readonly elapsedMs: number

Milliseconds elapsed between write-flush and timeout fire.

messageControlIdBytes​

readonly messageControlIdBytes: number | undefined

Byte length of the timed-out send's MSH-10 control ID, or undefined when there was none to read (FIFO mode, or a payload with no MSH-10).

The control ID itself is deliberately not here. An Error is a diagnostic surface: it is logged, and its stack is what an error reporter ships off the box. MSH-10 is payload content, and a scanner that returns the wrong field returns payload content of some other kind, which is how a patient identifier reaches a log line. Nothing is lost by withholding it, because this error rejects the very send() whose payload the caller passed in, so the caller already holds the bytes. Control IDs are decoded latin1, a 1:1 byte to code-unit map, so this count is a byte count.

name​

readonly name: "MllpTimeoutError"

Overrides​

Error.name

sentAt​

readonly sentAt: number

Epoch ms timestamp recorded at write-flush callback.


NetTransport​

Wraps a net.Socket as a Transport, mapping socket EventEmitter events to registered single-handler callbacks.

Each onXxx(fn) call replaces the prior handler and re-registers on the socket (uses socket.removeAllListeners(event) + socket.on(event, fn)).

Example​

const t = new NetTransport(socket);
t.onData((chunk) => process(chunk));
t.onError((err) => handleError(err));

Implements​

Constructors​

Constructor​

new NetTransport(socket): NetTransport

Wrap an existing net.Socket (or tls.TLSSocket) as a Transport.

Parameters​
socket​

Socket

The connected (or connecting) socket to adapt.

Returns​

NetTransport

Methods​

close()​

close(): void

See Transport.close.

Returns​

void

Implementation of​

Transport.close

destroy()​

destroy(reason?): void

See Transport.destroy.

Parameters​
reason?​

Error

Returns​

void

Implementation of​

Transport.destroy

onClose()​

onClose(fn): void

See Transport.onClose.

Parameters​
fn​

() => void

Returns​

void

Implementation of​

Transport.onClose

onConnect()​

onConnect(fn): void

See Transport.onConnect.

Parameters​
fn​

() => void

Returns​

void

Implementation of​

Transport.onConnect

onData()​

onData(fn): void

See Transport.onData.

Parameters​
fn​

(chunk) => void

Returns​

void

Implementation of​

Transport.onData

onError()​

onError(fn): void

See Transport.onError.

Parameters​
fn​

(err) => void

Returns​

void

Implementation of​

Transport.onError

write()​

write(buf): boolean

See Transport.write.

Parameters​
buf​

Buffer

Returns​

boolean

Implementation of​

Transport.write


TlsTransport​

Wraps a tls.TLSSocket as a Transport. onConnect(fn) is armed on the 'secureConnect' event (handshake complete, including certificate verification when rejectUnauthorized is on) rather than the raw TCP 'connect' event that NetTransport uses.

Each onXxx(fn) call replaces the prior handler and re-registers on the socket (socket.removeAllListeners(event) + socket.on(event, fn)), the same set-once semantics as NetTransport.

Example​

const t = new TlsTransport(tlsSocket);
t.onConnect(() => logger.info('secure'));
t.onError((err) => handleTlsError(err));

Implements​

Constructors​

Constructor​

new TlsTransport(socket): TlsTransport

Wrap an existing tls.TLSSocket (connecting or already secure) as a Transport.

Parameters​
socket​

TLSSocket

The TLS socket to adapt.

Returns​

TlsTransport

Methods​

close()​

close(): void

See Transport.close.

Returns​

void

Implementation of​

Transport.close

destroy()​

destroy(reason?): void

See Transport.destroy.

Parameters​
reason?​

Error

Returns​

void

Implementation of​

Transport.destroy

onClose()​

onClose(fn): void

See Transport.onClose.

Parameters​
fn​

() => void

Returns​

void

Implementation of​

Transport.onClose

onConnect()​

onConnect(fn): void

See Transport.onConnect. Armed on 'secureConnect', the TLS handshake-complete event, not the raw TCP 'connect' event.

Parameters​
fn​

() => void

Returns​

void

Implementation of​

Transport.onConnect

onData()​

onData(fn): void

See Transport.onData.

Parameters​
fn​

(chunk) => void

Returns​

void

Implementation of​

Transport.onData

onError()​

onError(fn): void

See Transport.onError.

Parameters​
fn​

(err) => void

Returns​

void

Implementation of​

Transport.onError

write()​

write(buf): boolean

See Transport.write.

Parameters​
buf​

Buffer

Returns​

boolean

Implementation of​

Transport.write

Interfaces​

AckCorrelationWarning​

The 'warning' payload the client emits for an ACK-correlation deviation (MLLP_ACK_UNMATCHED_CONTROL_ID, MLLP_ACK_AFTER_TIMEOUT).

A MllpWarning with two extra numeric fields. message is a frozen registry entry, byte-for-byte identical for a given code no matter what arrived on the wire, and everything input-derived is a number: the control ID's byte length and the elapsed time. A warning is a log line, so it carries no field content.

Example​

client.on('warning', (w: AckCorrelationWarning) => {
logger.warn({ code: w.code, idBytes: w.controlIdBytes, elapsedMs: w.elapsedSinceSendMs });
});

Extends​

Properties​

byteOffset​

readonly byteOffset: number

Absolute stream byte offset where the anomaly was detected.

Inherited from​

MllpWarning.byteOffset

code​

readonly code: AckCorrelationCode

The correlation code this warning reports.

Overrides​

MllpWarning.code

connectionId​

readonly connectionId: string | undefined

Connection identifier. undefined at the framing layer; enriched by Connection before emitting upstream.

Inherited from​

MllpWarning.connectionId

controlIdBytes​

readonly controlIdBytes: number | null

Byte length of the control ID involved, or null when there was none to read. Control IDs are decoded latin1, so a code-unit count is a byte count.

elapsedSinceSendMs​

readonly elapsedSinceSendMs: number

Milliseconds between the send's write-flush (or its timeout, for a late ACK) and this warning.

message​

readonly message: string

Stable human-readable description. It carries structural facts (a byte offset, an accumulated size) and never a run of payload content.

Two codes carry exactly one input byte, and it is worth knowing which: 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 MllpFramingError.snippet carries. If that is more than your threat model allows, log code and byteOffset rather than message.

The ACK-correlation codes are stricter: their text comes from a frozen registry and does not vary with the wire at all.

Inherited from​

MllpWarning.message

timestamp​

readonly timestamp: Date

Wall-clock time at point of emission.

Inherited from​

MllpWarning.timestamp


ClientOptions​

Options for createClient and the MllpClient constructor.

Example​

const opts: ClientOptions = { host: 'localhost', port: 2575, drainTimeoutMs: 10_000 };

Properties​

ackTimeoutMs?​

readonly optional ackTimeoutMs?: number

Per-message ACK timeout in milliseconds. The clock starts at the underlying write() flush callback, NOT at the send() call, pre-flush queue time is not charged to the peer. Default: 30_000.

autoReconnect?​

readonly optional autoReconnect?: boolean

Auto-reconnect on transient disconnect. Default false.

When true, dropped connections caused by transient errors (per isTransientConnectionError) trigger the FSM cycle CONNECTED → DISCONNECTED → RECONNECTING → CONNECTING → CONNECTED with exponential backoff unless overridden by ClientOptions.retryStrategy. Permanent errors halt and transition directly to CLOSED.

correlateByControlId?​

readonly optional correlateByControlId?: boolean

If true, ACKs are matched against outgoing sends by MSH-10 → MSA-2. Default false (FIFO mode).

Out-of-order ACKs from the peer are supported in this mode. MSH-10 is extracted from the outbound payload before send; MSA-2 is extracted from the inbound ACK payload. An ACK whose MSA-2 matches no pending send (and is not in the late-ACK graveyard) emits a frozen MllpFramingError('MLLP_ACK_UNMATCHED_CONTROL_ID') to the 'error' event. A late ACK whose MSA-2 matches a graveyard entry emits a MLLP_ACK_AFTER_TIMEOUT warning and is dropped.

Default​
false
deadPeerTimeoutMs?​

readonly optional deadPeerTimeoutMs?: number

Application-idle timeout (ms) keyed on last inbound bytes / ACK / warning. On trip, calls connection.destroy(new Error('dead peer timeout')) which surfaces as MllpConnectionError({ phase: 'receive' }). Trip honors ClientOptions.autoReconnect. Independent of ClientOptions.keepaliveIntervalMs.

Default​
undefined (off)
drainTimeoutMs?​

readonly optional drainTimeoutMs?: number

Drain timeout for MllpClient.close (default: 30_000 ms).

framing?​

readonly optional framing?: Omit<FrameReaderOptions, "onFrame" | "onWarning">

FrameReader tolerance / size options. onFrame and onWarning are managed internally.

highWaterMark?​

readonly optional highWaterMark?: HighWaterMark

Application-level high-water mark on the in-flight + queued send set. number configures a count cap (default 64); { bytes } configures a byte cap; { count, bytes } configures both, with the stricter-of-two trigger winning.

When the cap is exceeded, behavior is governed by ClientOptions.onBackpressure.

Default​
64
host​

readonly host: string

Host to connect to (e.g. 'localhost' or 'mllp.example.com').

initialDelayMs?​

readonly optional initialDelayMs?: number

First delay (ms) on auto-reconnect; default 100.

jitter?​

readonly optional jitter?: number

Jitter fraction, e.g. 0.2 = ±20%; default 0.2.

keepaliveIntervalMs?​

readonly optional keepaliveIntervalMs?: number

TCP keepalive interval (ms). Sets socket.setKeepAlive(true, ms) on the underlying net.Socket BEFORE wrapping in NetTransport. OS-level half-open detection (network partitions, NAT-table eviction). Independent of ClientOptions.deadPeerTimeoutMs.

Default​
undefined (off)
maxDelayMs?​

readonly optional maxDelayMs?: number

Maximum backoff cap (ms); default 30_000.

multiplier?​

readonly optional multiplier?: number

Backoff multiplier; default 2.

onBackpressure?​

readonly optional onBackpressure?: "reject" | "wait"

Behavior when the high-water mark is exceeded.

  • 'reject' (default), send() rejects with MllpBackpressureError.
  • 'wait', send() awaits the 'drain' event OR the per-message ackTimeoutMs OR signal abort, whichever fires first.
Default​
'reject'
pipeline?​

readonly optional pipeline?: boolean

Strict serialization send → await-ACK → send.

  • true (default), concurrent in-flight sends up to ClientOptions.highWaterMark.
  • false, collapses the in-flight set to ≤1 (the unified Correlator's maxInFlight=1); the next send() waits for the prior ACK before reaching the wire.
Default​
true
port​

readonly port: number

TCP port.

retryStrategy?​

readonly optional retryStrategy?: RetryStrategy

Custom reconnect-backoff hook. Return null to halt reconnection. Receives a frozen RetryContext. Defaults to the exponential strategy.

tls?​

readonly optional tls?: true | TlsOptions

Enable TLS (MLLPS) for this connection. true enables TLS with all defaults, including certificate verification on. Pass a TlsOptions object to customize (ca/cert/key, minimum version, ciphers, allowUnverified, …).

Spec anchor: IHE ATNA ITI-19 (https://profiles.ihe.net/ITI/TF/Volume2/ITI-19.html).

Default​
undefined (plaintext TCP)

ClientStats​

Observability snapshot returned by MllpClient.getStats.

All fields are JSON-serializable, no Buffers, no class instances, no Maps, no circular references. lastConnectedAt and lastAckAt are epoch milliseconds (numbers), NOT Date instances, log-pipeline friendly.

warningsByCode keys are constrained to the public WarningCode union, adding/removing a code is a breaking change (CLAUDE.md stable-codes guardrail enforced at the type boundary).

Example​

const stats = client.getStats();
logger.info(JSON.stringify(stats));
// {"state":"CONNECTED","connectionId":"…","queueDepth":0, … }

Properties​

ackedTotal​

readonly ackedTotal: number

Total ACKs matched + resolved since construction.

connectionId​

readonly connectionId: string | null

Live Connection's id, or null before the first connect (or post-CLOSED).

inFlight​

readonly inFlight: number

Entries with sentAt !== null, actually written to the wire / awaiting ACK.

lastAckAt​

readonly lastAckAt: number | null

Epoch ms of the most recent successful ACK. null until first ACK.

lastConnectedAt​

readonly lastConnectedAt: number | null

Epoch ms of the last CONNECTED transition. null until first connect.

queueBytes​

readonly queueBytes: number

Sum of frame.length across live correlator entries.

queueDepth​

readonly queueDepth: number

Total live correlator entries (in-flight + pre-flush + serialization-queued).

reconnectAttempts​

readonly reconnectAttempts: number

Total reconnect attempts since construction.

sentTotal​

readonly sentTotal: number

Total successful connection.send() calls since construction.

state​

readonly state: ConnectionState

Current FSM state, mirrors client.state.

timedOutTotal​

readonly timedOutTotal: number

Total ACK timeouts since construction.

tls​

readonly tls: boolean

Whether this client is configured for TLS. Mirrors ClientOptions.tls being set.

totalBytesIn​

readonly totalBytesIn: number

Bytes received from the peer (current Connection).

totalBytesOut​

readonly totalBytesOut: number

Bytes written to the peer (current Connection).

warningsByCode​

readonly warningsByCode: Partial<Record<WarningCode, number>>

Aggregated warning counts. Keys are constrained to the public WarningCode union. Connection-level warnings + Correlator MLLP_ACK_* warnings are merged.


ConnectionOptions​

Options for constructing a Connection.

Example​

const opts: ConnectionOptions = {
transport: new NetTransport(socket),
onMessage: (payload) => handleMessage(payload),
onWarning: (w) => logger.warn(w),
drainTimeoutMs: 10_000,
};

Properties​

drainTimeoutMs?​

optional drainTimeoutMs?: number

Drain timeout used by close() (default: 30_000 ms).

framing?​

optional framing?: Omit<FrameReaderOptions, "onFrame" | "onWarning">

FrameReader options (tolerance, maxFrameSizeBytes). onFrame/onWarning are managed internally.

onMessage?​

optional onMessage?: (payload) => void

Called for each decoded MLLP frame (raw payload bytes, framing stripped).

Parameters​
payload​

Buffer

Returns​

void

onWarning?​

optional onWarning?: (w) => void

Per-connection warning subscriber. Replaces previous subscription.

Parameters​
w​

MllpWarning

Returns​

void

transport​

transport: Transport

The transport this Connection will drive.


ConnectionStats​

Return type of connection.getStats(), JSON-serializable.

All timestamps are Date | null (not epoch milliseconds). JSON.stringify() serialises them to ISO 8601 strings by default with no information loss.

Example​

const stats = conn.getStats();
logger.info(JSON.stringify(stats)); // safe: all values are JSON-serializable

Properties​

bytesIn​

readonly bytesIn: number

bytesOut​

readonly bytesOut: number

connectedAt​

readonly connectedAt: Date | null

connectionId​

readonly connectionId: string

lastByteInAt​

readonly lastByteInAt: Date | null

lastByteOutAt​

readonly lastByteOutAt: Date | null

remoteAddress​

readonly remoteAddress: string | null

remotePort​

readonly remotePort: number | null

state​

readonly state: ConnectionState

warningsByCode​

readonly warningsByCode: Record<string, number>

warningsTruncated​

readonly warningsTruncated: boolean


EncoderOptions​

Options for encodeFrame.

Example​

const frame = encodeFrame(payload, {
allowDelimiterBytesInPayload: true,
onWarning: (w) => logger.warn(w),
});

Properties​

allowDelimiterBytesInPayload?​

optional allowDelimiterBytesInPayload?: boolean

When true, VT (0x0B) or FS (0x1C) bytes within the payload are preserved verbatim in the output frame instead of throwing MllpFramingError. An MllpWarning is emitted per offending byte if onWarning is provided.

Default: false (strict, throws on delimiter bytes).

onWarning?​

optional onWarning?: (w) => void

Called for each offending delimiter byte when allowDelimiterBytesInPayload is true. Invocation is wrapped in try/catch, a throwing handler does not interrupt encoding.

Parameters​
w​

MllpWarning

Returns​

void

Example​
const frame = encodeFrame(payload, {
allowDelimiterBytesInPayload: true,
onWarning: (w) => logger.warn({ code: w.code, offset: w.byteOffset }),
});

FrameReaderOptions​

Options for FrameReader.

All tolerance opts default to false, every framing deviation throws unless explicitly enabled. Server-level defaults (allowFsOnly, allowLfAfterFs, allowLeadingWhitespace) are applied by the server when constructing readers.

Example​

const opts: FrameReaderOptions = {
onFrame: (payload, byteOffset, warnings) => process(payload, byteOffset, warnings),
onWarning: (w) => logger.warn(w),
maxFrameSizeBytes: 4 * 1024 * 1024, // 4 MiB limit
allowFsOnly: true,
allowLfAfterFs: true,
};

Properties​

allowFsOnly?​

optional allowFsOnly?: boolean

Tolerate FS without trailing CR; emits MLLP_FS_WITHOUT_CR.

allowLeadingWhitespace?​

optional allowLeadingWhitespace?: boolean

Tolerate SP/TAB/LF/CR before VT; emits MLLP_LEADING_WHITESPACE.

allowLfAfterFs?​

optional allowLfAfterFs?: boolean

Tolerate FS+LF instead of FS+CR; emits MLLP_LF_AFTER_FS.

allowMissingLeadingVt?​

optional allowMissingLeadingVt?: boolean

Tolerate missing leading VT; emits MLLP_MISSING_LEADING_VT.

maxFrameSizeBytes?​

optional maxFrameSizeBytes?: number

Maximum accumulated payload bytes before MllpFramingError('MLLP_FRAME_TOO_LARGE'). Default: 16 MiB (DoS prevention).

onFrame​

onFrame: (payload, byteOffset, warnings) => void

Called synchronously during push() for each complete MLLP payload.

Parameters​
payload​

Buffer

Raw MLLP payload bytes (framing stripped).

byteOffset​

number

Stream byte offset of the VT byte that opened this frame. Monotonic across the connection lifetime; reset to 0 by reset().

warnings​

readonly MllpWarning[]

Framing warnings emitted during decoding of this specific frame. Empty array when no tolerance deviations were detected.

Returns​

void

onWarning?​

optional onWarning?: (w) => void

Called for each tolerated framing deviation. Wrapped in try/catch. A throwing handler will not corrupt FSM state.

Parameters​
w​

MllpWarning

Returns​

void

strict?​

optional strict?: boolean

When true, escalates the following tolerances to thrown MllpFramingError even if individual opt-ins (allowFsOnly, allowLfAfterFs, allowMissingLeadingVt, allowLeadingWhitespace) are enabled:

  • MLLP_MISSING_LEADING_VT
  • MLLP_FS_WITHOUT_CR
  • MLLP_LF_AFTER_FS
  • MLLP_LEADING_WHITESPACE (leading whitespace escalates as MLLP_MISSING_LEADING_VT)

MLLP_EMPTY_PAYLOAD and MLLP_TRAILING_BYTES remain warnings even in strict mode.

Example​
// Hardened enforcement, no tolerance, all violations throw
const reader = new FrameReader({ onFrame: fn, strict: true });

MessageMeta​

Metadata attached to each decoded MLLP message.

All fields are readonly, the object is Object.freeze()'d before emission.

Example​

server.on('message', ({ payload, meta }) => {
console.log(meta.connectionId, '@', meta.byteOffset, 'warnings:', meta.warnings.length);
});

Properties​

byteOffset​

readonly byteOffset: number

Byte offset of the frame start in the connection's data stream.

connectionId​

readonly connectionId: string

Stable UUID identifying the connection that delivered this message.

warnings​

readonly warnings: readonly MllpWarning[]

Framing warnings emitted during decoding of this frame.


MllpWarning​

A frozen warning object emitted when the decoder tolerates a framing deviation.

connectionId is undefined when emitted by a standalone FrameReader. Connection enriches it to the real UUIDv4 before forwarding upstream.

Example​

const reader = new FrameReader({
onFrame: (p) => process(p),
onWarning: (w: MllpWarning) => logger.warn(w),
allowFsOnly: true,
});

Extended by​

Properties​

byteOffset​

readonly byteOffset: number

Absolute stream byte offset where the anomaly was detected.

code​

readonly code: WarningCode

connectionId​

readonly connectionId: string | undefined

Connection identifier. undefined at the framing layer; enriched by Connection before emitting upstream.

message​

readonly message: string

Stable human-readable description. It carries structural facts (a byte offset, an accumulated size) and never a run of payload content.

Two codes carry exactly one input byte, and it is worth knowing which: 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 MllpFramingError.snippet carries. If that is more than your threat model allows, log code and byteOffset rather than message.

The ACK-correlation codes are stricter: their text comes from a frozen registry and does not vary with the wire at all.

timestamp​

readonly timestamp: Date

Wall-clock time at point of emission.


NackEvent​

Payload of the server 'nack' event, emitted whenever the server responds with a negative acknowledgement instead of AA on the auto-ACK path (the fail-safe commit contract). This fires both when a commit-gated autoAck: 'AA' handler throws/rejects and when the server downgrades a positive auto-ACK because the inbound could not be correlated (NackReason).

PHI-safe by construction: carries only the connection ID, the resolved acknowledgement code, and a static reason, never the payload, the inbound control ID, or the thrown error's message (which may carry PHI). The object is Object.freeze()'d before emission.

Example​

server.on('nack', ({ connectionId, ackCode, reason }) => {
metrics.increment('mllp.nack', { code: ackCode, reason }); // e.g. reason='discarded-bytes'
});

Properties​

ackCode​

readonly ackCode: NegativeAckCode

The negative acknowledgement code sent to the peer (AE or AR).

connectionId​

readonly connectionId: string

Connection that produced the negative acknowledgement.

reason​

readonly reason: NackReason

Why the negative acknowledgement was sent (PHI-free).


ReconnectingEvent​

Payload for the 'reconnecting' event. Always Object.freeze'd.

attempt and delayMs are populated by MllpClient when it schedules a reconnect. A Connection emitting on its own supplies connectionId only.

Example​

conn.on('reconnecting', ({ connectionId }) => {
logger.info(`Reconnecting connection ${connectionId}`);
});

Properties​

attempt?​

readonly optional attempt?: number

connectionId​

readonly connectionId: string

delayMs?​

readonly optional delayMs?: number


RetryContext​

Context passed to a custom retryStrategy hook on each reconnect attempt.

Frozen via Object.freeze before invocation, handlers cannot mutate.

Example​

const retryStrategy: RetryStrategy = (ctx) => {
if (ctx.attempt >= 5) return null;
if (ctx.classifiedAs === 'permanent') return null;
return Math.min(30_000, 1000 * (ctx.attempt + 1));
};

Properties​

attempt​

readonly attempt: number

0-indexed attempt counter for the current reconnect cycle.

classifiedAs​

readonly classifiedAs: "transient" | "permanent"

How the failure that triggered this reconnect attempt was classified.

lastDelayMs​

readonly lastDelayMs: number

Delay used for the previous attempt (ms). 0 on the first attempt.

lastError​

readonly lastError: Error

The error that triggered the disconnect.

signal​

readonly signal: AbortSignal

The same AbortSignal passed into connect(). If no signal was supplied, the module-level NEVER_ABORTING_SIGNAL sentinel is provided so handlers always have a real AbortSignal to inspect.

sinceLastSuccessMs​

readonly sinceLastSuccessMs: number

Ms since the last successful ACK. Infinity if no success seen.

totalElapsedMs​

readonly totalElapsedMs: number

Total wall-clock ms elapsed since the disconnect that started this cycle.


SecurityWarning​

Frozen payload of the 'securityWarning' event, emitted by both MllpClient (MLLP_TLS_VERIFY_DISABLED) and MllpServer (MLLP_BIND_ALL_INTERFACES).

Never carries payload bytes or PHI, only routing metadata (host/port) and a fixed, static message string.

Example​

client.on('securityWarning', (w: SecurityWarning) => {
logger.warn({ code: w.code, host: w.host, port: w.port });
});

Properties​

code​

readonly code: SecurityWarningCode

The stable security-warning code.

host​

readonly host: string

Host associated with the warning (the target host for clients; the bind host for servers).

message​

readonly message: string

Fixed, human-readable description. Never contains payload bytes or PHI.

port​

readonly port: number

Port associated with the warning.

timestamp​

readonly timestamp: Date

Wall-clock time at point of emission.


ServerOptions​

Options for createServer().

Example​

const opts: ServerOptions = {
onMessage: (payload, meta, conn) => {
const ack = buildAck(payload);
conn.send(ack);
},
framing: { maxFrameSizeBytes: 4 * 1024 * 1024 },
keepaliveIntervalMs: 60_000,
deadPeerTimeoutMs: 300_000,
drainTimeoutMs: 30_000,
};

Extended by​

Properties​

allowWildcardBind?​

optional allowWildcardBind?: boolean

Opt-in required to bind a wildcard host, a bind-safety guardrail. Without this flag, listen() rejects a wildcard host in two tiers: literal spellings ('0.0.0.0', '::', '', '::0', '0:0:0:0:0:0:0:0', '::ffff:0.0.0.0', …) reject pre-bind (fast path, nothing is ever bound); resolver-only shorthands ('0', '0.0', '0x0.0.0.0', hostnames resolving to the unspecified address, …) are caught post-bind against the OS-normalized bound address, the just-bound server closes before any connection can be accepted and listen() rejects, with no listening state and no 'listening' event either way. When true, binding a wildcard host emits a one-time 'securityWarning' (MLLP_BIND_ALL_INTERFACES) at listen time.

Default​
false
autoAck?​

optional autoAck?: "AA" | ((payload, meta, conn) => Buffer<ArrayBufferLike> | Promise<Buffer<ArrayBufferLike>>)

Auto-ACK mode. When set, the server builds and sends the ACK for each message.

  • 'AA', auto-acknowledge with the fail-safe commit contract:
    • With an onMessage handler ⇒ commit-gated (recommended). The server awaits onMessage (the durable-commit step), then sends AA on success or a negative ACK on failure (AE by default; AR via MllpAckError). The positive ACK cannot precede a successful commit, a handler throw can never yield AA.
    • Without an onMessage handler ⇒ transport-accept. AA is sent on frame receipt. This AA means only "bytes received and framed", not "application-processed". ⚠️ For clinical messages this is unsafe on its own: pair 'AA' with an onMessage handler that durably commits, so the ACK reflects real processing.
  • fn, fn(payload, meta, conn) builds the ACK bytes the server sends; the caller fully owns MSA-1 (e.g. to emit enhanced-mode CA/CE/CR).

The 'message' event always fires BEFORE the ACK is sent. Do NOT call conn.send() in onMessage when autoAck is set, this results in two ACKs.

deadPeerTimeoutMs?​

optional deadPeerTimeoutMs?: number

Application-level idle close timeout in ms. If no HL7 messages are received on a connection for this interval, the connection is destroyed via conn.destroy(new Error('idle timeout')). Resets on every 'message' event. Distinct from keepaliveIntervalMs (OS TCP probe).

drainTimeoutMs?​

optional drainTimeoutMs?: number

Graceful drain timeout passed to conn.close() during server.close(). Default: 30 000 ms.

framing?​

optional framing?: Omit<FrameReaderOptions, "onFrame" | "onWarning">

FrameReader tolerance options applied to every accepted connection. Merged with SERVER_DEFAULT_FRAMING, caller-supplied values override defaults. onFrame and onWarning are managed internally and must not be supplied here.

keepaliveIntervalMs?​

optional keepaliveIntervalMs?: number

TCP keepalive probe interval in ms. Calls socket.setKeepAlive(true, ms) on each accepted socket. Uses OS TCP stack to detect dead peers (half-open, network partitions). Distinct from deadPeerTimeoutMs (application-level idle close).

onMessage?​

optional onMessage?: (payload, meta, conn) => void | Promise<void>

Called for each decoded MLLP message.

Its role depends on autoAck, this is the commit contract (HL7 v2.5.1 §2.9.2):

  • autoAck: 'AA' + this handler ⇒ commit-gated (the safe default). The handler is the durable-commit step. The server awaits it and only then sends the ACK: resolve ⇒ AA; throw/reject ⇒ AE (or AR via MllpAckError), a positive ACK can never precede a successful commit. Do not call conn.send() here in this mode.
  • autoAck unset ⇒ manual mode. The handler owns the response; build and send the ACK yourself via conn.send(encodeFrame(ackPayload)). Its return value is ignored.
  • autoAck: fn ⇒ observation only. fn builds the ACK; this handler runs first as a side effect and its return value is ignored. Do not call conn.send() here.

May be sync or async; an async handler is awaited in commit-gated mode.

Parameters​
payload​

Buffer

meta​

MessageMeta

conn​

Connection

Returns​

void | Promise<void>

onWarning?​

optional onWarning?: (w) => void

Per-connection warning subscriber. Called for every framing warning on every connection.

Parameters​
w​

MllpWarning

Returns​

void

tls?​

optional tls?: ServerTlsOptions

Enable TLS (MLLPS) for this server. When set, the server binds a tls.Server instead of a plain net.Server, consumes 'secureConnection' (post-handshake sockets) instead of 'connection', and surfaces failed handshakes via the 'tlsClientError' event rather than crashing.

Spec anchor: IHE ATNA ITI-19 (https://profiles.ihe.net/ITI/TF/Volume2/ITI-19.html).

Default​
undefined (plaintext TCP)

ServerStats​

Observability snapshot returned by server.getStats().

All fields are JSON-serializable. connections and activeConnections both reflect the current live connection count.

Example​

const stats = server.getStats();
console.log(JSON.stringify(stats)); // log-pipeline friendly

Properties​

acceptedTotal​

readonly acceptedTotal: number

Total connections accepted since listen() (monotonically increasing).

activeConnections​

readonly activeConnections: number

Current live connection count. Same value as connections.

closedTotal​

readonly closedTotal: number

Total connections closed since listen() (monotonically increasing).

connections​

readonly connections: number

Current live connection count. Same value as activeConnections.

host​

readonly host: string | null

Bound host, or null before listen().

listening​

readonly listening: boolean

Whether the server is currently accepting connections.

port​

readonly port: number | null

Bound port, or null before listen().

tls​

readonly tls: boolean

Whether this server is configured for TLS. Mirrors ServerOptions.tls being set.

tlsClientErrorsTotal​

readonly tlsClientErrorsTotal: number

Total 'tlsClientError' events (failed TLS handshakes, incl. rejected client certs) since listen().

totalBytesIn​

readonly totalBytesIn: number

Aggregate bytes received across all current connections.

totalBytesOut​

readonly totalBytesOut: number

Aggregate bytes sent across all current connections.


ServerTlsOptions​

Server-side TLS options (ServerOptions.tls).

Example​

import { createServer } from '@cosyte/mllp';
const server = createServer({
tls: { cert: certPem, key: keyPem, clientAuth: 'MUST', ca: clientCaPem },
});

Properties​

ca?​

readonly optional ca?: PemInput

Trust anchor(s) for verifying client certificates under WANT/MUST.

cert​

readonly cert: PemInput

Server certificate (PEM). Required.

ciphers?​

readonly optional ciphers?: string

OpenSSL cipher-list string passthrough. Unset uses Node's defaults (includes both ATNA-mandated ECDHE suites).

Default​
undefined (Node defaults)
clientAuth?​

readonly optional clientAuth?: ClientAuth

ATNA ITI-19 mutual-authentication mode. 'WANT' requests a client certificate without rejecting unauthorized/absent ones (surfaced, not enforced); 'MUST' requests AND enforces verification (ATNA mutual auth). See ClientAuth.

Default​
'NONE'
key​

readonly key: PemInput

Private key matching ServerTlsOptions.cert. Required.

maxVersion?​

readonly optional maxVersion?: "TLSv1.2" | "TLSv1.3"

Maximum negotiated TLS protocol version.

minVersion?​

readonly optional minVersion?: "TLSv1.2" | "TLSv1.3"

Minimum negotiated TLS protocol version, the IHE ATNA ITI-19 "TLS 1.2 Floor" (BCP195) floor (ITI TF-2 §3.19.6.2.3).

Default​
'TLSv1.2'
passphrase?​

readonly optional passphrase?: string

Passphrase for an encrypted ServerTlsOptions.key.


StarterClientOptions​

Options for createStarterClient.

The starter applies opinionated defaults on top of ClientOptions, so every override here is optional except host + port. The starter-specific addition is handleSignals (mirrors createStarterServer).

Example​

const opts: StarterClientOptions = {
host: 'localhost',
port: 2575,
onMessage: (payload) => logger.info({ bytes: payload.length }),
handleSignals: true,
};

Properties​

ackTimeoutMs?​

readonly optional ackTimeoutMs?: number

Override default 30_000.

autoReconnect?​

readonly optional autoReconnect?: boolean

Override default true (auto-reconnect on transient errors).

correlateByControlId?​

readonly optional correlateByControlId?: boolean

Override default false (FIFO mode).

deadPeerTimeoutMs?​

readonly optional deadPeerTimeoutMs?: number

Application-idle dead-peer timeout ms.

drainTimeoutMs?​

readonly optional drainTimeoutMs?: number

Drain timeout for close() (default 30_000).

framing?​

readonly optional framing?: Omit<FrameReaderOptions, "onFrame" | "onWarning">

FrameReader options (passthrough).

handleSignals?​

readonly optional handleSignals?: boolean

Register process SIGTERM/SIGINT handlers that close the client. Default false. When true, SIGTERM/SIGINT both call client.close() and exit the process. Handlers self-deregister on 'close'.

highWaterMark?​

readonly optional highWaterMark?: HighWaterMark

Override default 64.

host​

readonly host: string

Host to connect to.

keepaliveIntervalMs?​

readonly optional keepaliveIntervalMs?: number

TCP keepalive interval ms.

onBackpressure?​

readonly optional onBackpressure?: "reject" | "wait"

Override default 'reject'.

onMessage?​

readonly optional onMessage?: (payload) => void

Inbound-message callback (any framed payload from the peer, including non-ACK messages on bidirectional channels). Mirrors the server-side onMessage ergonomics.

Parameters​
payload​

Buffer

Returns​

void

pipeline?​

readonly optional pipeline?: boolean

Override default true (parallel up to highWaterMark).

port​

readonly port: number

TCP port.

retryStrategy?​

readonly optional retryStrategy?: RetryStrategy

Custom reconnect-backoff hook.

tls?​

readonly optional tls?: true | TlsOptions

Enable TLS (MLLPS) for this connection. Passthrough to ClientOptions.tls.


StarterServerOptions​

Options for createStarterServer(), the "three lines of code" factory.

Extends ServerOptions with port, host, and handleSignals. Defaults: autoAck: 'AA', drainTimeoutMs: 30_000, Symbol.asyncDispose wired.

Example​

import { createStarterServer } from '@cosyte/mllp';

const server = await createStarterServer({
port: 2575,
onMessage: async (payload) => {
await db.commit(payload); // the durable-commit step; a throw answers a negative ACK
},
});
// server is listening, auto-ACK enabled, Symbol.asyncDispose wired
await using _ = server; // closes on scope exit

Extends​

Properties​

allowWildcardBind?​

optional allowWildcardBind?: boolean

Opt-in required to bind a wildcard host, a bind-safety guardrail. Without this flag, listen() rejects a wildcard host in two tiers: literal spellings ('0.0.0.0', '::', '', '::0', '0:0:0:0:0:0:0:0', '::ffff:0.0.0.0', …) reject pre-bind (fast path, nothing is ever bound); resolver-only shorthands ('0', '0.0', '0x0.0.0.0', hostnames resolving to the unspecified address, …) are caught post-bind against the OS-normalized bound address, the just-bound server closes before any connection can be accepted and listen() rejects, with no listening state and no 'listening' event either way. When true, binding a wildcard host emits a one-time 'securityWarning' (MLLP_BIND_ALL_INTERFACES) at listen time.

Default​
false
Inherited from​

ServerOptions.allowWildcardBind

autoAck?​

optional autoAck?: "AA" | ((payload, meta, conn) => Buffer<ArrayBufferLike> | Promise<Buffer<ArrayBufferLike>>)

Auto-ACK mode. When set, the server builds and sends the ACK for each message.

  • 'AA', auto-acknowledge with the fail-safe commit contract:
    • With an onMessage handler ⇒ commit-gated (recommended). The server awaits onMessage (the durable-commit step), then sends AA on success or a negative ACK on failure (AE by default; AR via MllpAckError). The positive ACK cannot precede a successful commit, a handler throw can never yield AA.
    • Without an onMessage handler ⇒ transport-accept. AA is sent on frame receipt. This AA means only "bytes received and framed", not "application-processed". ⚠️ For clinical messages this is unsafe on its own: pair 'AA' with an onMessage handler that durably commits, so the ACK reflects real processing.
  • fn, fn(payload, meta, conn) builds the ACK bytes the server sends; the caller fully owns MSA-1 (e.g. to emit enhanced-mode CA/CE/CR).

The 'message' event always fires BEFORE the ACK is sent. Do NOT call conn.send() in onMessage when autoAck is set, this results in two ACKs.

Inherited from​

ServerOptions.autoAck

deadPeerTimeoutMs?​

optional deadPeerTimeoutMs?: number

Application-level idle close timeout in ms. If no HL7 messages are received on a connection for this interval, the connection is destroyed via conn.destroy(new Error('idle timeout')). Resets on every 'message' event. Distinct from keepaliveIntervalMs (OS TCP probe).

Inherited from​

ServerOptions.deadPeerTimeoutMs

drainTimeoutMs?​

optional drainTimeoutMs?: number

Graceful drain timeout passed to conn.close() during server.close(). Default: 30 000 ms.

Inherited from​

ServerOptions.drainTimeoutMs

framing?​

optional framing?: Omit<FrameReaderOptions, "onFrame" | "onWarning">

FrameReader tolerance options applied to every accepted connection. Merged with SERVER_DEFAULT_FRAMING, caller-supplied values override defaults. onFrame and onWarning are managed internally and must not be supplied here.

Inherited from​

ServerOptions.framing

handleSignals?​

optional handleSignals?: boolean

Register process.once('SIGTERM') and process.once('SIGINT') handlers that call server.close() then process.exit(0). Default: false.

Handlers are automatically removed when server.close() is called, so process.listenerCount('SIGTERM') === 0 after close() completes, preventing handler accumulation across test instances or multiple server restarts.

host?​

optional host?: string

Host to bind to. Default '127.0.0.1' (bind-safety hardening, was '0.0.0.0'; binding all interfaces now requires ServerOptions.allowWildcardBind: true).

keepaliveIntervalMs?​

optional keepaliveIntervalMs?: number

TCP keepalive probe interval in ms. Calls socket.setKeepAlive(true, ms) on each accepted socket. Uses OS TCP stack to detect dead peers (half-open, network partitions). Distinct from deadPeerTimeoutMs (application-level idle close).

Inherited from​

ServerOptions.keepaliveIntervalMs

onMessage?​

optional onMessage?: (payload, meta, conn) => void | Promise<void>

Called for each decoded MLLP message.

Its role depends on autoAck, this is the commit contract (HL7 v2.5.1 §2.9.2):

  • autoAck: 'AA' + this handler ⇒ commit-gated (the safe default). The handler is the durable-commit step. The server awaits it and only then sends the ACK: resolve ⇒ AA; throw/reject ⇒ AE (or AR via MllpAckError), a positive ACK can never precede a successful commit. Do not call conn.send() here in this mode.
  • autoAck unset ⇒ manual mode. The handler owns the response; build and send the ACK yourself via conn.send(encodeFrame(ackPayload)). Its return value is ignored.
  • autoAck: fn ⇒ observation only. fn builds the ACK; this handler runs first as a side effect and its return value is ignored. Do not call conn.send() here.

May be sync or async; an async handler is awaited in commit-gated mode.

Parameters​
payload​

Buffer

meta​

MessageMeta

conn​

Connection

Returns​

void | Promise<void>

Inherited from​

ServerOptions.onMessage

onWarning?​

optional onWarning?: (w) => void

Per-connection warning subscriber. Called for every framing warning on every connection.

Parameters​
w​

MllpWarning

Returns​

void

Inherited from​

ServerOptions.onWarning

port​

port: number

Port to listen on.

tls?​

optional tls?: ServerTlsOptions

Enable TLS (MLLPS) for this server. When set, the server binds a tls.Server instead of a plain net.Server, consumes 'secureConnection' (post-handshake sockets) instead of 'connection', and surfaces failed handshakes via the 'tlsClientError' event rather than crashing.

Spec anchor: IHE ATNA ITI-19 (https://profiles.ihe.net/ITI/TF/Volume2/ITI-19.html).

Default​
undefined (plaintext TCP)
Inherited from​

ServerOptions.tls


StateChangeEvent​

Payload for the 'stateChange' event. Always Object.freeze'd.

Example​

conn.on('stateChange', ({ from, to, reason }) => {
logger.info({ from, to, reason });
});

Properties​

from​

readonly from: ConnectionState

reason?​

readonly optional reason?: string

to​

readonly to: ConnectionState


TlsOptions​

Client-side TLS options (ClientOptions.tls).

Passing true for ClientOptions.tls is equivalent to {}, TLS enabled with all defaults, including certificate verification on.

Example​

import { createClient } from '@cosyte/mllp';
const client = createClient({
host: 'mllp.example.com',
port: 2575,
tls: { ca: caPem, minVersion: 'TLSv1.2' },
});

Properties​

allowUnverified?​

readonly optional allowUnverified?: boolean

Loud, explicit dev opt-out from certificate verification (maps to tls.connect's rejectUnauthorized: false). There is deliberately no raw rejectUnauthorized surface on this type, this is the only door, and it is loud: every successful connection (initial + every reconnect) emits a 'securityWarning' (MLLP_TLS_VERIFY_DISABLED) event and calls process.emitWarning.

Never set this in production against an untrusted network.

Default​
false
ca?​

readonly optional ca?: PemInput

Trust anchor(s) for verifying the server's certificate chain.

cert?​

readonly optional cert?: PemInput

Client certificate presented for mutual TLS (ATNA ITI-19).

ciphers?​

readonly optional ciphers?: string

OpenSSL cipher-list string passthrough (tls.connect's ciphers). Unset uses Node's compiled-in defaults, which already include both ATNA-mandated ECDHE suites (see the module doc comment). Set this to restrict to a stricter list (e.g. DHE suites) if your deployment requires it.

Default​
undefined (Node defaults)
key?​

readonly optional key?: PemInput

Private key matching TlsOptions.cert.

maxVersion?​

readonly optional maxVersion?: "TLSv1.2" | "TLSv1.3"

Maximum negotiated TLS protocol version.

minVersion?​

readonly optional minVersion?: "TLSv1.2" | "TLSv1.3"

Minimum negotiated TLS protocol version.

Default 'TLSv1.2', the IHE ATNA ITI-19 "TLS 1.2 Floor" (BCP195) floor (ITI TF-2 §3.19.6.2.3). 'TLSv1.0'/'TLSv1.1' are intentionally not expressible by this type, the floor cannot be lowered through this API.

Default​
'TLSv1.2'
passphrase?​

readonly optional passphrase?: string

Passphrase for an encrypted TlsOptions.key.

servername?​

readonly optional servername?: string

SNI hostname and the identity-check target (matched against the server certificate's Subject/SAN). Defaults to ClientOptions.host when unset.


Transport​

Pure callback-bag transport abstraction.

Each onXxx registration is set-once: calling onData(fn) a second time replaces the first handler. This prevents listener leaks across reconnect cycles where Connection re-registers handlers on a fresh Transport.

Example​

function writeAll(t: Transport, chunks: Buffer[]): void {
for (const chunk of chunks) t.write(chunk);
}

Methods​

close()​

close(): void

Initiate graceful close of the underlying transport. The registered onClose handler fires once the socket is fully closed.

Returns​

void

destroy()​

destroy(reason?): void

Abruptly destroy the transport, discarding any pending writes. Fires onError(reason) (if provided) then onClose.

Parameters​
reason?​

Error

Optional error describing why the transport was destroyed.

Returns​

void

onClose()​

onClose(fn): void

Register the close handler. Called once when the underlying socket is fully closed. Replaces any previously registered handler (set-once semantics).

Parameters​
fn​

() => void

Returns​

void

onConnect()​

onConnect(fn): void

Register the connect handler. Called once after TCP (or TLS) handshake completes. Replaces any previously registered handler (set-once semantics).

Parameters​
fn​

() => void

Returns​

void

onData()​

onData(fn): void

Register the data handler. Called synchronously for each received chunk. Replaces any previously registered handler (set-once semantics).

Parameters​
fn​

(chunk) => void

Called with each raw chunk as it arrives from the OS.

Returns​

void

onError()​

onError(fn): void

Register the error handler. Called for socket-level errors (ECONNRESET, ETIMEDOUT, etc.). Replaces any previously registered handler (set-once semantics).

Parameters​
fn​

(err) => void

Receives the underlying OS or TLS error.

Returns​

void

write()​

write(buf): boolean

Write buf to the underlying transport.

Parameters​
buf​

Buffer

Returns​

boolean

true if the bytes were flushed to the kernel immediately; false if the write was buffered (backpressure, caller should pause sending until the onDrain event fires at the Connection layer).

Type Aliases​

AckCode​

AckCode = "AA" | "AE" | "AR" | "CA" | "CE" | "CR"

HL7 Table 0008, Acknowledgment Code. A stable public API.

Two families, by mode (HL7 v2.5.1 §2.9):

  • Original mode (§2.9.2): AA accept, AE application error, AR application reject. The single ACK reports application-level outcome.
  • Enhanced mode (§2.9.3): CA commit accept, CE commit error, CR commit reject, the accept acknowledgement, distinct from a later application ACK.

AE vs AR: AE is a processing error (the sender may resend later, e.g. a transient downstream outage); AR is a reject (the sender should not resend the message unchanged, e.g. it is structurally unacceptable). @cosyte/mllp builds original-mode ACKs; the C* codes are surfaced in the type for completeness and for callers that build their own enhanced-mode ACKs via autoAck: fn.

Example​

import type { AckCode } from '@cosyte/mllp';
const code: AckCode = 'AE';

AckCorrelationCode​

AckCorrelationCode = "MLLP_ACK_UNMATCHED_CONTROL_ID" | "MLLP_ACK_AFTER_TIMEOUT"

The two warning codes emitted by ACK correlation rather than by framing.

A subset of the framing WarningCode union; named separately so the registry below is exhaustive over exactly the codes it owns and a new correlation code cannot be added without a message for it.


ClientAuth​

ClientAuth = "NONE" | "WANT" | "MUST"

ATNA ITI-19 mutual-authentication modes for ServerTlsOptions.clientAuth.

Mirrors the IHE ATNA "Authenticate Node" mutual-auth requirement (https://profiles.ihe.net/ITI/TF/Volume2/ITI-19.html):

  • 'NONE', no client certificate requested (default).
  • 'WANT', client certificate requested but NOT required; an untrusted or absent client certificate does not reject the connection. The peer certificate (if any) is surfaced on the 'connection' event.
  • 'MUST', client certificate required AND verified against ServerTlsOptions.ca; the ATNA mutual-authentication mode. A missing or untrusted client certificate rejects the handshake.

Example​

import type { ClientAuth } from '@cosyte/mllp';
const mode: ClientAuth = 'MUST'; // ATNA ITI-19 mutual node authentication

ConnectionErrorCause​

ConnectionErrorCause = "fifo-unsafe" | "in-flight-orphan" | "tls-verify" | "tls-handshake" | "framing-fatal"

Stable cause codes for MllpConnectionError.

These codes are a public API, they appear in error inspection by callers, log pipelines, and monitoring dashboards. Renaming or removing a member is a breaking change.

  • 'fifo-unsafe', A queued send was rejected during reconnect because FIFO ordering cannot be safely resumed across sessions.
  • 'in-flight-orphan', An in-flight send (already write-flushed, ACK timer started) was rejected during reconnect in FIFO mode because the at-most-once delivery contract cannot be preserved across a socket drop (healthcare medication/orders semantics).
  • 'tls-verify', The TLS handshake failed certificate verification (untrusted chain, expired/not-yet-valid cert, hostname mismatch, revocation, …). See isTlsVerificationErrorCode for the exact underlying error codes. Classified permanent by isTransientConnectionError, never auto-reconnect-looped into a misconfigured or MITM'd endpoint.
  • 'tls-handshake', A TLS-protocol-shaped handshake failure observed before 'secureConnect': ERR_SSL_* codes, EPROTO, or an OpenSSL alert-bearing error (protocol version mismatch, no shared cipher, a required mutual-TLS client certificate rejected by the server, …). See isTlsProtocolError for the boundary. Pure TCP-level failures (ECONNREFUSED, ETIMEDOUT, …) on a TLS-configured connection carry no connectionCause, the same shape as plaintext.

Scope note: the 'tls-verify'/'tls-handshake' values are attached on the client's initial connect() path. Failures on the auto-reconnect path surface as raw socket errors, their transient/permanent classification still applies, but they do not (yet) carry a connectionCause.

Example​

if (err instanceof MllpConnectionError && err.connectionCause === 'in-flight-orphan') {
// Treat as at-most-once: do NOT auto-retry; bubble up to caller for
// application-level dedupe / replay decisions.
}

ConnectionErrorPhase​

ConnectionErrorPhase = "connect" | "send" | "receive" | "close" | "reconnect"

Connection lifecycle phase where the error occurred.

All 5 phases are defined even though 'reconnect' is only exercised by the client. Locking the full union now prevents a breaking type change later.


ConnectionState​

ConnectionState = "CONNECTING" | "CONNECTED" | "DRAINING" | "RECONNECTING" | "DISCONNECTED" | "CLOSED"

The 6 connection states.

Transitions are validated against the legal-transition edge graph. Illegal transitions are silently ignored to preserve FSM integrity.


NackReason​

NackReason = "handler-rejected" | "uncorrelatable-inbound" | "discarded-bytes"

Why the server sent a negative acknowledgement instead of AA on the auto-ACK path. A stable, PHI-free enum (no payload bytes, no control ID), safe to log and to key metrics on.

  • 'handler-rejected', a commit-gated onMessage handler threw/rejected (the commit contract: a positive ACK cannot precede a successful commit).
  • 'uncorrelatable-inbound', the inbound could not carry a correlatable positive ACK (no readable MSH, an empty MSH-10, or a batch/concatenated-message shape). A positive AA here names a control ID the sender cannot match → timeout → resend → duplicate clinical message. See rawAckUncorrelatable.
  • 'discarded-bytes', the decoder flagged MLLP_TRAILING_BYTES for this frame: a mid-payload VT made it discard accumulated bytes and deliver only the fragment after it. The clinical message was destroyed in transit; a positive AA would tell the sender a message we never received was delivered.

NegativeAckCode​

NegativeAckCode = "AE" | "AR"

The negative original-mode acknowledgement codes a failed handler can produce.

Example​

import type { NegativeAckCode } from '@cosyte/mllp';
const code: NegativeAckCode = 'AR';

PemInput​

PemInput = string | Buffer | (string | Buffer)[]

A PEM-encoded credential (certificate, key, or CA), matching Node's tls.connect/tls.createServer input shape, a single PEM string/Buffer, or an array of them (chain / multiple trust anchors).

Example​

import { readFileSync } from 'node:fs';
const ca: PemInput = readFileSync('ca.pem');

RetryStrategy​

RetryStrategy = (ctx) => number | null

Custom reconnect-backoff hook. Return null to halt reconnection, the FSM transitions to CLOSED.

Parameters​

ctx​

RetryContext

Returns​

number | null


SecurityWarningCode​

SecurityWarningCode = typeof MLLP_TLS_VERIFY_DISABLED | typeof MLLP_BIND_ALL_INTERFACES

Union of the two stable security-warning codes.

Example​

const code: SecurityWarningCode = 'MLLP_TLS_VERIFY_DISABLED';

WarningCode​

WarningCode = "MLLP_MISSING_LEADING_VT" | "MLLP_FS_WITHOUT_CR" | "MLLP_LF_AFTER_FS" | "MLLP_LEADING_WHITESPACE" | "MLLP_TRAILING_BYTES" | "MLLP_PAYLOAD_CONTAINS_VT" | "MLLP_PAYLOAD_CONTAINS_FS" | "MLLP_EMPTY_PAYLOAD" | "MLLP_FRAME_TOO_LARGE" | "MLLP_ACK_UNMATCHED_CONTROL_ID" | "MLLP_ACK_AFTER_TIMEOUT"

Union of all stable MLLP warning codes.

These codes are a public API, they appear in onWarning handlers, log pipelines, monitoring dashboards, and error messages. Renaming is a breaking change.

Example​

const code: WarningCode = 'MLLP_FS_WITHOUT_CR';

Variables​

MLLP_BIND_ALL_INTERFACES​

const MLLP_BIND_ALL_INTERFACES: "MLLP_BIND_ALL_INTERFACES" = "MLLP_BIND_ALL_INTERFACES"

Emitted (server-side) once at listen() time when the server binds a wildcard host ('0.0.0.0' or '::') with ServerOptions.allowWildcardBind: true. Binding all interfaces widens the network exposure of the listener; this is the loud, one-time reminder that the operator opted in.

Example​

import { MLLP_BIND_ALL_INTERFACES } from '@cosyte/mllp';
server.on('securityWarning', (w) => {
if (w.code === MLLP_BIND_ALL_INTERFACES) logger.warn(w.message);
});

MLLP_TLS_VERIFY_DISABLED​

const MLLP_TLS_VERIFY_DISABLED: "MLLP_TLS_VERIFY_DISABLED" = "MLLP_TLS_VERIFY_DISABLED"

Emitted (client-side) on every successful secureConnect, initial connect AND every reconnect, when TlsOptions.allowUnverified is true. Certificate verification is disabled for the connection; this is the loud, per-connection reminder that the channel is not authenticated per IHE ATNA ITI-19 (https://profiles.ihe.net/ITI/TF/Volume2/ITI-19.html).

Example​

import { MLLP_TLS_VERIFY_DISABLED } from '@cosyte/mllp';
client.on('securityWarning', (w) => {
if (w.code === MLLP_TLS_VERIFY_DISABLED) metrics.increment('mllp.tls_verify_disabled');
});

VERSION​

const VERSION: string = "0.0.10"

Package version marker exported from the @cosyte/mllp root.

Kept in lockstep with package.json by scripts/sync-version.mjs, which the version script runs immediately after changeset version. The : string annotation is deliberate, without it TypeScript infers the literal type (declare const VERSION = "0.0.0"), which leaks the current release into consumers' types and makes an equality check against any other version a compile error.

Example​

import { VERSION } from '@cosyte/mllp';
console.log(VERSION);

Functions​

ackDiagnosticMessage()​

ackDiagnosticMessage(code): string

Look up the frozen diagnostic text for an ACK-correlation code.

A pure table read. It takes no value, so no caller can widen it into an interpolation site.

Parameters​

code​

AckCorrelationCode

The correlation code being reported.

Returns​

string

The registered message for that code, byte-for-byte.

Example​

const message = ackDiagnosticMessage('MLLP_ACK_AFTER_TIMEOUT');

buildRawAck()​

buildRawAck(payload, code): Buffer

Build a minimal original-mode HL7 v2 acknowledgement from raw inbound payload bytes, without a parser (parser-driven ACKs are the @cosyte/mllp/ack-from-hl7 subpath).

Locates the MSH segment (the first CR/LF-delimited segment starting with MSH, so a leading CR or an FHS/BHS batch header does not hide it), reads the field separator from MSH-1 and the encoding characters from MSH-2, then splits that segment on that separator to read fields. The ACK swaps sender/receiver per HL7 ACK rules, echoes the inbound MSH-10 into MSA-2 (§2.9.2.2), sets MSA-1 to code, and, for negative codes, adds a static, PHI-free MSA-3 reason. A fresh control ID fills the ACK's own MSH-10.

Why the delimiters are read, not assumed​

MSH-1 is the field separator (HL7 v2.5.1 §2.5.4): the byte at offset 3 of the MSH segment defines it for the whole message. | is overwhelmingly common but it is a convention, not the spec. Assuming it was wrong in two compounding ways:

  1. Reading. Splitting a !-delimited message on | yields ONE field, so every echoed field, including MSH-10, came back empty. The ACK went out as MSA|AA| with no correlation id at all: the sender cannot match it, times out, and resends → a duplicate clinical message. That is the exact failure this package's correlator exists to prevent, manufactured by the ACK builder itself.
  2. Writing. The echoed MSH-3..6 and MSH-10 field content is copied verbatim from the inbound, still escaped against the inbound's encoding characters. Re- emitting that content under a different delimiter set silently reinterprets it: an inbound whose component separator is # and whose MSH-10 is ID#X would be re-emitted under ^~\& as the literal ID#X, which the sender then reads as a single component rather than two. Echoing MSH-1 and MSH-2 keeps the content and the delimiters that define it together.

It reads the MSH through the SHARED scanner​

The MSH read is readMshSegment from src/internal/control-id.ts, the same call the client's correlator makes to derive the key it will later match this ACK against. That is deliberate and it is the whole point: this builder used to re-derive the read itself (payload.toString("latin1").split("\r"), hunting for an MSH anywhere in the payload), and the two disagreed on real inputs. On a truncated MSH followed by a PID the correlator keyed on the PID's MRN while this builder echoed an empty MSA-2. On a payload with a leading CR, which the MLLP decoder passes straight through, and which real senders emit, this builder found the MSH and echoed MSH-10 correctly while the correlator, requiring MSH at byte 0, gave up. Every such disagreement is an ACK the sender cannot match → timeout → resend → duplicate clinical message.

The fix is one scan, but note which scan. The first attempt made them agree by requiring MSH at byte 0 everywhere, which "resolved" the leading-CR disagreement by degrading the side that had been right: buildRawAck began emitting a positive AA with an empty MSA-2, silently, for a message whose MSH-10 was plainly present. Agreement is not the goal; agreeing on the correct, tolerant answer is. A lenient reader may never drop data that is there (Postel's Law, CLAUDE.md).

The fail-safe downgrade: never a positive AA/CA it cannot correlate​

A positive acknowledgement is a promise the sender may forget the message. If it names a control ID the sender cannot match, or names one message out of several it never read, the sender times out and resends, committing a duplicate clinical message. So a requested positive code is downgraded to its non-positive counterpart (AA→AE, CA→CE) whenever the payload cannot carry a correlatable positive ACK: no readable MSH, an empty MSH-10, or a batch/concatenated-message shape that a single MSA-2 cannot acknowledge. See rawAckUncorrelatable for the exact conditions and why each is a refusal rather than a widened reader. A requested negative code (AE/AR/CE/CR) is never touched. This mirrors the parser-backed buildMllpAck, which downgrades and warns on an unparseable inbound, the two builders' fail-safe semantics now agree.

Never throws and never copies payload content beyond the routing/control metadata above, readMshSegment stops at the MSH's segment terminator, so no field of any later segment (PID and friends) can be reached, let alone echoed. On a missing or unreadable MSH it returns a minimal well-formed ACK carrying the (downgraded) code so the caller can still respond.

Parameters​

payload​

Buffer

Raw decoded HL7 v2 payload bytes (MLLP framing already stripped).

code​

AckCode

Requested MSA-1 acknowledgement code. A positive AA/CA is downgraded to AE/CE when the message cannot be correlated (see above).

Returns​

Buffer

ACK payload bytes (no framing, the caller wraps with encodeFrame).

Example​

import { buildRawAck } from '@cosyte/mllp';
const ack = buildRawAck(inboundPayload, 'AE'); // MSA|AE|<inbound MSH-10>|message could not be processed

createClient()​

createClient(opts): MllpClient

Create an MllpClient. Equivalent to new MllpClient(opts).

Parameters​

opts​

ClientOptions

Returns​

MllpClient

Example​

import { createClient } from '@cosyte/mllp';

const client = createClient({ host: 'localhost', port: 2575 });
await client.connect();
const ack = await client.send(payloadBuffer);
await client.close();

createServer()​

createServer(opts?): MllpServer

Factory function, creates a new MllpServer with the supplied options.

Prefer createServer() over new MllpServer() for forward-compatible construction.

Parameters​

opts?​

ServerOptions = {}

Returns​

MllpServer

Example​

import { createServer } from '@cosyte/mllp';

const server = createServer({
onMessage: (payload, meta, conn) => {
console.log('received', payload.length, 'bytes');
},
});
await server.listen(2575);

createStarterClient()​

createStarterClient(opts): Promise<MllpClient>

Three-line MLLP client with batteries-included defaults. The returned client is already CONNECTED, connect() has been awaited.

Defaults:

  • autoReconnect: true
  • ackTimeoutMs: 30_000
  • correlateByControlId: false (FIFO mode, simplest mental model)
  • pipeline: true
  • highWaterMark: 64
  • onBackpressure: 'reject'
  • handleSignals: false (opt-in)

The factory is async, so the literal three-line north-star snippet has an explicit await BEFORE createStarterClient(...), without it, the using declaration would receive a Promise, not an MllpClient, and Symbol.asyncDispose would not run at scope exit.

Parameters​

opts​

StarterClientOptions

Returns​

Promise<MllpClient>

Example​

import { createStarterClient } from '@cosyte/mllp';
await using c = await createStarterClient({ host: 'localhost', port: 2575 });
const ack = await c.send(payloadBuffer);

createStarterServer()​

createStarterServer(opts): Promise<MllpServer>

Starter factory, creates, configures, and starts an MllpServer in one call.

Provides the "three lines of code" north-star experience with sensible defaults: autoAck: 'AA', drainTimeoutMs: 30_000, Symbol.asyncDispose wired.

Parameters​

opts​

StarterServerOptions

Returns​

Promise<MllpServer>

Example​

import { createStarterServer } from '@cosyte/mllp';

const server = await createStarterServer({
port: 2575,
onMessage: async (payload) => {
await db.commit(payload); // the durable-commit step; a throw answers a negative ACK
},
});

createWarning()​

createWarning(code, byteOffset, message): MllpWarning

Create a frozen MllpWarning object. The returned object is Object.freeze()'d so subscribers cannot mutate shared warning state.

connectionId is always undefined here; Connection enriches it via { ...w, connectionId: this.connectionId } then re-freezes.

Parameters​

code​

WarningCode

byteOffset​

number

message​

string

Returns​

MllpWarning

Example​

const w = createWarning('MLLP_EMPTY_PAYLOAD', 64, 'Empty payload between VT and FS');
// w.connectionId === undefined

encodeFrame()​

encodeFrame(payload, opts?): Buffer

Encode payload in canonical MLLP framing: VT (0x0B) + payload + FS (0x1C) + CR (0x0D).

Strict by default: throws MllpFramingError if the payload contains VT or FS bytes. Set { allowDelimiterBytesInPayload: true } to pass those bytes through verbatim and receive an MllpWarning per offending byte via onWarning instead.

The returned Buffer has length payload.length + 3. Bytes at positions 1 through payload.length are an exact copy of the payload (independent of the input buffer).

Parameters​

payload​

Buffer

opts?​

EncoderOptions

Returns​

Buffer

Throws​

with code MLLP_PAYLOAD_CONTAINS_VT when payload contains byte 0x0B and allowDelimiterBytesInPayload is false (default).

Throws​

with code MLLP_PAYLOAD_CONTAINS_FS when payload contains byte 0x1C and allowDelimiterBytesInPayload is false (default).

Example​

import { encodeFrame } from '@cosyte/mllp';

// Strict (default), throws on delimiter bytes in payload
const frame = encodeFrame(Buffer.from('MSH|^~\\&|SEND|FAC|RECV|FAC|...'));
socket.write(frame);

// Tolerant, passes delimiter bytes through with a warning
const frame2 = encodeFrame(dirtyPayload, {
allowDelimiterBytesInPayload: true,
onWarning: (w) => logger.warn(w),
});

isTlsProtocolError()​

isTlsProtocolError(err): boolean

Detects TLS-protocol-shaped errors, failures of the TLS protocol itself, as opposed to plain TCP-level network failures.

Apply this only to errors raised on a TLS connection; MllpClient does exactly that (the predicate is consulted only when ClientOptions.tls is set). The boundary:

TLS-protocol-shaped (true):

  • code starting ERR_SSL_ (Node's TLS alert codes, e.g. ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED, a clientAuth: 'MUST' server rejecting the client's certificate).
  • code === 'EPROTO', on a TLS connection this is OpenSSL failing the handshake (protocol version mismatch, no shared cipher, a TLS ≤1.2 mTLS rejection).
  • message containing ssl or alert (/\bssl\b|\balert\b/i, "SSL routines", "tlsv13 alert certificate required", …). This message check is a heuristic backstop over the code-based checks above, not a precise boundary: it exists to catch OpenSSL errors that surface without a usable code, and MllpClient consults it only on connections where TLS is configured. An arbitrary non-TLS error whose message happens to contain those words would also match.

NOT TLS-protocol-shaped (false), plain network failures, which stay transient for the reconnect classifier: ECONNREFUSED, ETIMEDOUT, EHOSTUNREACH, ENETUNREACH, EPIPE, and a plain ECONNRESET carrying no TLS alert context, a network blip during (or after) a handshake should still auto-heal.

Certificate-verification failures are a separate class, see isTlsVerificationErrorCode; MllpClient checks that first and labels those connectionCause: 'tls-verify'.

Why this matters: under TLS 1.3 (RFC 8446 §4.4.2) a clientAuth: 'MUST' server can reject the client's certificate AFTER the client's own 'secureConnect', the rejection then surfaces as a post-connect socket error. A misconfigured mTLS client must never auto-reconnect-loop against a server that will always reject it, so MllpClient classifies TLS-protocol-shaped errors as permanent.

Parameters​

err​

unknown

Returns​

boolean

Example​

import { isTlsProtocolError, MllpConnectionError } from '@cosyte/mllp';
client.on('error', ({ error }) => {
if (error instanceof MllpConnectionError && isTlsProtocolError(error.cause)) {
// TLS protocol failure, a configuration problem, not a network blip.
}
});

isTlsVerificationErrorCode()​

isTlsVerificationErrorCode(code): boolean

Set of Node/OpenSSL error codes that indicate a certificate-verification failure (as opposed to some other TLS handshake failure), untrusted chain, expired/not-yet-valid certificate, hostname mismatch, revocation, etc.

Used by MllpClient to classify a TLS connect failure's connectionCause as 'tls-verify' (this set) vs 'tls-handshake' (everything else observed before 'secureConnect'). Exported so callers can apply the same classification to their own error handling.

Parameters​

code​

string

Returns​

boolean

Example​

import { isTlsVerificationErrorCode } from '@cosyte/mllp';
if (isTlsVerificationErrorCode('CERT_HAS_EXPIRED')) {
// definitely a verification failure, not a protocol/cipher mismatch
}

isTransientConnectionError()​

isTransientConnectionError(err): boolean

Classifies a connection error as transient (eligible for auto-reconnect) or permanent (halts auto-reconnect, transitions to CLOSED).

Used internally by MllpClient BEFORE invoking retryStrategy (see RetryContext.classifiedAs). Re-exported so callers can implement their own retry policies.

Classification table:

  • ENOTFOUND, EACCES → permanent (false)
  • ECONNREFUSED, ECONNRESET, ETIMEDOUT, EHOSTUNREACH, ENETUNREACH, EPIPE → transient (true)
  • CERT_* and UNABLE_TO_VERIFY_LEAF_SIGNATURE / DEPTH_ZERO_SELF_SIGNED_CERT / SELF_SIGNED_CERT_IN_CHAIN (any isTlsVerificationErrorCode code) → permanent (false), never auto-reconnect-loop into a misconfigured or MITM'd endpoint.
  • ERR_SSL_* (Node TLS alert codes) → permanent (false), a TLS protocol failure such as a clientAuth: 'MUST' server rejecting the client certificate recurs on every attempt. On TLS-configured connections MllpClient additionally consults isTlsProtocolError, which also catches EPROTO/alert-bearing OpenSSL errors that this generic classifier (which cannot know the connection was TLS) leaves transient.
  • MLLP_* (any MllpFramingError code, a fatal decoder throw, surfaced with connectionCause: 'framing-fatal') → permanent (false). The peer is not speaking MLLP, an HTTP probe, a health check, a wrong-port misconfiguration, or is emitting frames past maxFrameSizeBytes. Every reconnect meets the same bytes, so retrying is an unbounded storm against a peer that is already misconfigured. If a peer's quirk is expected, the decoder's tolerance opt-ins are the supported answer, they make it a warning, not a fatal.
  • non-Error / unknown / no-code → transient (true), Postel's Law default. Reconnect attempts are bounded by retryStrategy and the 30s backoff cap, so the default is safe.

Parameters​

err​

unknown

Returns​

boolean

Example​

import { isTransientConnectionError } from '@cosyte/mllp';
client.on('error', (err) => {
if (isTransientConnectionError(err)) {
metrics.increment('mllp.transient_error');
} else {
metrics.increment('mllp.permanent_error');
}
});

rawAckUncorrelatable()​

rawAckUncorrelatable(payload): boolean

True iff a positive raw acknowledgement (AA/CA) cannot be safely correlated to this payload, so buildRawAck (and the server's auto-ACK path) must downgrade it to a non-positive AE/CE rather than tell the sender "I have it."

The rule this enforces: never answer AA for a message you could not correlate. A positive ACK is a promise the sender may forget the message; if it names a control ID the sender cannot match, or names one of several messages it never read, the sender times out and resends, committing a duplicate clinical message (or worse, believes a destroyed message was delivered). Three payload-shaped reasons make a positive ACK uncorrelatable, all peer-reachable off the wire:

  1. No readable MSH. readMshSegment returns null, e.g. a BOM/SP/TAB before MSH (which shares the MSH's segment line, so MSH heads no segment), or a bare fragment delivered after a mid-payload VT discard (MLLP_TRAILING_BYTES). MSA-2 would be empty: an ACK that correlates to nothing.
  2. Empty MSH-10. The MSH is readable but carries no message control ID, so there is nothing to echo, again MSA|AA| with an empty MSA-2.
  3. A batch or concatenated messages. An FHS/BHS/BTS/FTS envelope (§2.10.3) or a second MSH in the same frame: a single MSA-2 can echo only ONE control ID, so a positive ACK naming the first silently drops the rest (see containsBatchOrExtraMessage). Batch ACK is its own feature; until it is designed, a batch must stay a loud non-positive answer.

This is a refusal, not a tolerance widening: it never makes an unreadable message readable, never re-bases on a located MSH, never parses a batch. It only recognizes the shapes for which a positive disposition would be a lie, so the builder can fall back to AE. A negative requested code (AE/AR/CE/CR) is unaffected, it is already non-positive, and echoing whatever control ID it can find is still useful.

Pure, byte-level, never throws.

Parameters​

payload​

Buffer

Returns​

boolean

Example​

import { rawAckUncorrelatable } from '@cosyte/mllp';
rawAckUncorrelatable(oneGoodMessage); // false → AA is safe
rawAckUncorrelatable(twoConcatenatedMsh); // true → downgrade AA to AE

resolveNackCode()​

resolveNackCode(err): NegativeAckCode

Resolve the negative acknowledgement code for a handler failure.

An MllpAckError carries an explicit ackCode; any other thrown value maps to AE (application error, the default, since most handler failures are transient and a resend may succeed).

Parameters​

err​

unknown

Returns​

NegativeAckCode

Example​

import { resolveNackCode, MllpAckError } from '@cosyte/mllp';
resolveNackCode(new Error('boom')); // 'AE'
resolveNackCode(new MllpAckError('nope', { ackCode: 'AR' })); // 'AR'