@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
Connection options (the transport, message/warning handlers, drain timeout, framing).
Returns
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
readonlyconnectionId: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
Methods
close()
close(
opts?):Promise<void>
Initiate graceful close of the connection.
- If
CONNECTINGorRECONNECTING: cancels the pending attempt, transitions directly toCLOSED, and callstransport.destroy(). - If
CONNECTED: transitions toDRAINING, callsbeforeClose(drainTimeoutMs), thenDRAINING → DISCONNECTEDonce the hook resolves, orDRAINING → CLOSEDif the drain timeout elapses first. - If
DRAINING: waits for the existing drain to complete (idempotent). - If
CLOSEDorDISCONNECTED: 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
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 byteREADING_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
Reader options (onFrame/onWarning callbacks, tolerance, maxFrameSizeBytes).
Returns
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?
Negative code to return. Default 'AE'.
cause?
unknown
Underlying error, preserved on .cause.
Returns
Overrides
Error.constructor
Properties
ackCode
readonlyackCode: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
Overrides
Error.constructor
Properties
highWaterMark
readonlyhighWaterMark:object
The high-water-mark configuration that was triggered.
bytes?
readonlyoptionalbytes?:number
count?
readonlyoptionalcount?:number
name
readonlyname:"MllpBackpressureError"
Overrides
Error.name
queueBytes
readonlyqueueBytes:number
Total bytes of in-flight + queued frames at the moment of rejection.
queueDepth
readonlyqueueDepth: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 entersCONNECTED'disconnect',{ connectionId }once the FSM entersDISCONNECTED'reconnecting',{ connectionId, attempt?, delayMs? }'close',{ connectionId }once the FSM enters terminalCLOSED'message',{ payload, connectionId, byteOffset, warnings }for every inbound frame'warning',MllpWarning | AckCorrelationWarning. A framing deviation arrives as aMllpWarningenriched withconnectionIdfrom the Connection layer; the two ACK-correlation codes arrive as AckCorrelationWarning, which addscontrolIdBytesandelapsedSinceSendMs. Narrow oncodebefore reading either.'securityWarning',SecurityWarning. Emitted on every successfulsecureConnect(initial + every reconnect) whentls.allowUnverifiedistruewith codeMLLP_TLS_VERIFY_DISABLED. Also mirrored toprocess.emitWarning.'error', re-emitted from Connection. Guarded bylistenerCount('error') > 0so 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
Client options (host/port, ACK timeout, reconnect/backpressure policy, …).
Returns
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
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
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')ifsignalis provided and aborts before the connect resolves.MllpConnectionError({ phase: 'connect' })if the underlying socket emitserrorbefore connecting, OR if the client is already connecting/connected. TLS failures carry aconnectionCause:'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
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')ifsignalaborts before the ACK.MllpTimeoutErrorif no ACK arrives withinackTimeoutMs. The clock starts at the underlyingwrite()flush callback, NOT at thesend()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 errorphase, which connection lifecycle phase the failure occurred inconnectionCause, 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?
phase
Returns
Overrides
Error.constructor
Properties
cause
readonlycause:Error
The original OS or TLS error that caused this connection failure.
Overrides
Error.cause
connectionCause?
readonlyoptionalconnectionCause?: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
readonlyname:"MllpConnectionError"
Overrides
Error.name
phase
readonlyphase:ConnectionErrorPhase
Which connection lifecycle phase the failure occurred in.
MllpFramingError
Thrown for unrecoverable MLLP wire-format violations.
code, stableWarningCodeidentifying the violationbyteOffset, absolute stream position where the violation was detectedsnippet, 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 thesnippetPHI 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
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
Overrides
Error.constructor
Properties
byteOffset
readonlybyteOffset:number
Absolute byte offset in the stream where the error was detected.
code
readonlycode:WarningCode
Stable warning code identifying the violation type.
name
readonlyname:"MllpFramingError"
Overrides
Error.name
snippet
readonlysnippet: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
Server options (bind host/port, auto-ACK policy, message handler, framing, …).
Returns
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:
net.Server.close(), stops accepting new connections immediately- If
_connectionsis empty: resolves immediately (no drain needed) - 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
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
Overrides
Error.constructor
Properties
elapsedMs
readonlyelapsedMs:number
Milliseconds elapsed between write-flush and timeout fire.
messageControlIdBytes
readonlymessageControlIdBytes: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
readonlyname:"MllpTimeoutError"
Overrides
Error.name
sentAt
readonlysentAt: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
Methods
close()
close():
void
See Transport.close.
Returns
void
Implementation of
destroy()
destroy(
reason?):void
See Transport.destroy.
Parameters
reason?
Error
Returns
void
Implementation of
onClose()
onClose(
fn):void
See Transport.onClose.
Parameters
fn
() => void
Returns
void
Implementation of
onConnect()
onConnect(
fn):void
See Transport.onConnect.
Parameters
fn
() => void
Returns
void
Implementation of
onData()
onData(
fn):void
See Transport.onData.
Parameters
fn
(chunk) => void
Returns
void
Implementation of
onError()
onError(
fn):void
See Transport.onError.
Parameters
fn
(err) => void
Returns
void
Implementation of
write()
write(
buf):boolean
See Transport.write.
Parameters
buf
Buffer
Returns
boolean
Implementation of
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
Methods
close()
close():
void
See Transport.close.
Returns
void
Implementation of
destroy()
destroy(
reason?):void
See Transport.destroy.
Parameters
reason?
Error
Returns
void
Implementation of
onClose()
onClose(
fn):void
See Transport.onClose.
Parameters
fn
() => void
Returns
void
Implementation of
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
onData()
onData(
fn):void
See Transport.onData.
Parameters
fn
(chunk) => void
Returns
void
Implementation of
onError()
onError(
fn):void
See Transport.onError.
Parameters
fn
(err) => void
Returns
void
Implementation of
write()
write(
buf):boolean
See Transport.write.
Parameters
buf
Buffer
Returns
boolean
Implementation of
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
readonlybyteOffset:number
Absolute stream byte offset where the anomaly was detected.
Inherited from
code
readonlycode:AckCorrelationCode
The correlation code this warning reports.
Overrides
connectionId
readonlyconnectionId:string|undefined
Connection identifier. undefined at the framing layer;
enriched by Connection before emitting upstream.
Inherited from
controlIdBytes
readonlycontrolIdBytes: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
readonlyelapsedSinceSendMs:number
Milliseconds between the send's write-flush (or its timeout, for a late ACK) and this warning.
message
readonlymessage: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
timestamp
readonlytimestamp:Date
Wall-clock time at point of emission.
Inherited from
ClientOptions
Options for createClient and the MllpClient constructor.
Example
const opts: ClientOptions = { host: 'localhost', port: 2575, drainTimeoutMs: 10_000 };
Properties
ackTimeoutMs?
readonlyoptionalackTimeoutMs?: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?
readonlyoptionalautoReconnect?: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?
readonlyoptionalcorrelateByControlId?: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?
readonlyoptionaldeadPeerTimeoutMs?: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?
readonlyoptionaldrainTimeoutMs?:number
Drain timeout for MllpClient.close (default: 30_000 ms).
framing?
readonlyoptionalframing?:Omit<FrameReaderOptions,"onFrame"|"onWarning">
FrameReader tolerance / size options. onFrame and onWarning are managed internally.
highWaterMark?
readonlyoptionalhighWaterMark?: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
readonlyhost:string
Host to connect to (e.g. 'localhost' or 'mllp.example.com').
initialDelayMs?
readonlyoptionalinitialDelayMs?:number
First delay (ms) on auto-reconnect; default 100.
jitter?
readonlyoptionaljitter?:number
Jitter fraction, e.g. 0.2 = ±20%; default 0.2.
keepaliveIntervalMs?
readonlyoptionalkeepaliveIntervalMs?: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?
readonlyoptionalmaxDelayMs?:number
Maximum backoff cap (ms); default 30_000.
multiplier?
readonlyoptionalmultiplier?:number
Backoff multiplier; default 2.
onBackpressure?
readonlyoptionalonBackpressure?:"reject"|"wait"
Behavior when the high-water mark is exceeded.
'reject'(default),send()rejects withMllpBackpressureError.'wait',send()awaits the'drain'event OR the per-messageackTimeoutMsORsignalabort, whichever fires first.
Default
'reject'
pipeline?
readonlyoptionalpipeline?: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'smaxInFlight=1); the nextsend()waits for the prior ACK before reaching the wire.
Default
true
port
readonlyport:number
TCP port.
retryStrategy?
readonlyoptionalretryStrategy?:RetryStrategy
Custom reconnect-backoff hook. Return null to halt
reconnection. Receives a frozen RetryContext. Defaults
to the exponential strategy.
tls?
readonlyoptionaltls?: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
readonlyackedTotal:number
Total ACKs matched + resolved since construction.
connectionId
readonlyconnectionId:string|null
Live Connection's id, or null before the first connect (or post-CLOSED).
inFlight
readonlyinFlight:number
Entries with sentAt !== null, actually written to the wire / awaiting ACK.
lastAckAt
readonlylastAckAt:number|null
Epoch ms of the most recent successful ACK. null until first ACK.
lastConnectedAt
readonlylastConnectedAt:number|null
Epoch ms of the last CONNECTED transition. null until first connect.
queueBytes
readonlyqueueBytes:number
Sum of frame.length across live correlator entries.
queueDepth
readonlyqueueDepth:number
Total live correlator entries (in-flight + pre-flush + serialization-queued).
reconnectAttempts
readonlyreconnectAttempts:number
Total reconnect attempts since construction.
sentTotal
readonlysentTotal:number
Total successful connection.send() calls since construction.
state
readonlystate:ConnectionState
Current FSM state, mirrors client.state.
timedOutTotal
readonlytimedOutTotal:number
Total ACK timeouts since construction.
tls
readonlytls:boolean
Whether this client is configured for TLS. Mirrors ClientOptions.tls being set.
totalBytesIn
readonlytotalBytesIn:number
Bytes received from the peer (current Connection).
totalBytesOut
readonlytotalBytesOut:number
Bytes written to the peer (current Connection).
warningsByCode
readonlywarningsByCode: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?
optionaldrainTimeoutMs?:number
Drain timeout used by close() (default: 30_000 ms).
framing?
optionalframing?:Omit<FrameReaderOptions,"onFrame"|"onWarning">
FrameReader options (tolerance, maxFrameSizeBytes). onFrame/onWarning are managed internally.
onMessage?
optionalonMessage?: (payload) =>void
Called for each decoded MLLP frame (raw payload bytes, framing stripped).
Parameters
payload
Buffer
Returns
void
onWarning?
optionalonWarning?: (w) =>void
Per-connection warning subscriber. Replaces previous subscription.
Parameters
w
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
readonlybytesIn:number
bytesOut
readonlybytesOut:number
connectedAt
readonlyconnectedAt:Date|null
connectionId
readonlyconnectionId:string
lastByteInAt
readonlylastByteInAt:Date|null
lastByteOutAt
readonlylastByteOutAt:Date|null
remoteAddress
readonlyremoteAddress:string|null
remotePort
readonlyremotePort:number|null
state
readonlystate:ConnectionState
warningsByCode
readonlywarningsByCode:Record<string,number>
warningsTruncated
readonlywarningsTruncated:boolean
EncoderOptions
Options for encodeFrame.
Example
const frame = encodeFrame(payload, {
allowDelimiterBytesInPayload: true,
onWarning: (w) => logger.warn(w),
});
Properties
allowDelimiterBytesInPayload?
optionalallowDelimiterBytesInPayload?: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?
optionalonWarning?: (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
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?
optionalallowFsOnly?:boolean
Tolerate FS without trailing CR; emits MLLP_FS_WITHOUT_CR.
allowLeadingWhitespace?
optionalallowLeadingWhitespace?:boolean
Tolerate SP/TAB/LF/CR before VT; emits MLLP_LEADING_WHITESPACE.
allowLfAfterFs?
optionalallowLfAfterFs?:boolean
Tolerate FS+LF instead of FS+CR; emits MLLP_LF_AFTER_FS.
allowMissingLeadingVt?
optionalallowMissingLeadingVt?:boolean
Tolerate missing leading VT; emits MLLP_MISSING_LEADING_VT.
maxFrameSizeBytes?
optionalmaxFrameSizeBytes?: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?
optionalonWarning?: (w) =>void
Called for each tolerated framing deviation. Wrapped in try/catch. A throwing handler will not corrupt FSM state.
Parameters
w
Returns
void
strict?
optionalstrict?:boolean
When true, escalates the following tolerances to thrown MllpFramingError
even if individual opt-ins (allowFsOnly, allowLfAfterFs, allowMissingLeadingVt,
allowLeadingWhitespace) are enabled:
MLLP_MISSING_LEADING_VTMLLP_FS_WITHOUT_CRMLLP_LF_AFTER_FSMLLP_LEADING_WHITESPACE(leading whitespace escalates asMLLP_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
readonlybyteOffset:number
Byte offset of the frame start in the connection's data stream.
connectionId
readonlyconnectionId:string
Stable UUID identifying the connection that delivered this message.
warnings
readonlywarnings: readonlyMllpWarning[]
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
readonlybyteOffset:number
Absolute stream byte offset where the anomaly was detected.
code
readonlycode:WarningCode
connectionId
readonlyconnectionId:string|undefined
Connection identifier. undefined at the framing layer;
enriched by Connection before emitting upstream.
message
readonlymessage: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
readonlytimestamp: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
readonlyackCode:NegativeAckCode
The negative acknowledgement code sent to the peer (AE or AR).
connectionId
readonlyconnectionId:string
Connection that produced the negative acknowledgement.
reason
readonlyreason: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?
readonlyoptionalattempt?:number
connectionId
readonlyconnectionId:string
delayMs?
readonlyoptionaldelayMs?: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
readonlyattempt:number
0-indexed attempt counter for the current reconnect cycle.
classifiedAs
readonlyclassifiedAs:"transient"|"permanent"
How the failure that triggered this reconnect attempt was classified.
lastDelayMs
readonlylastDelayMs:number
Delay used for the previous attempt (ms). 0 on the first attempt.
lastError
readonlylastError:Error
The error that triggered the disconnect.
signal
readonlysignal: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
readonlysinceLastSuccessMs:number
Ms since the last successful ACK. Infinity if no success seen.
totalElapsedMs
readonlytotalElapsedMs: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
readonlycode:SecurityWarningCode
The stable security-warning code.
host
readonlyhost:string
Host associated with the warning (the target host for clients; the bind host for servers).
message
readonlymessage:string
Fixed, human-readable description. Never contains payload bytes or PHI.
port
readonlyport:number
Port associated with the warning.
timestamp
readonlytimestamp: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?
optionalallowWildcardBind?: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?
optionalautoAck?:"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
onMessagehandler ⇒ commit-gated (recommended). The server awaitsonMessage(the durable-commit step), then sendsAAon success or a negative ACK on failure (AEby default;ARvia MllpAckError). The positive ACK cannot precede a successful commit, a handler throw can never yieldAA. - Without an
onMessagehandler ⇒ transport-accept.AAis sent on frame receipt. ThisAAmeans only "bytes received and framed", not "application-processed". ⚠️ For clinical messages this is unsafe on its own: pair'AA'with anonMessagehandler that durably commits, so the ACK reflects real processing.
- With an
fn,fn(payload, meta, conn)builds the ACK bytes the server sends; the caller fully owns MSA-1 (e.g. to emit enhanced-modeCA/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?
optionaldeadPeerTimeoutMs?: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?
optionaldrainTimeoutMs?:number
Graceful drain timeout passed to conn.close() during server.close().
Default: 30 000 ms.
framing?
optionalframing?: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?
optionalkeepaliveIntervalMs?: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?
optionalonMessage?: (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(orARvia MllpAckError), a positive ACK can never precede a successful commit. Do not callconn.send()here in this mode.autoAckunset ⇒ manual mode. The handler owns the response; build and send the ACK yourself viaconn.send(encodeFrame(ackPayload)). Its return value is ignored.autoAck: fn⇒ observation only.fnbuilds the ACK; this handler runs first as a side effect and its return value is ignored. Do not callconn.send()here.
May be sync or async; an async handler is awaited in commit-gated mode.
Parameters
payload
Buffer
meta
conn
Returns
void | Promise<void>
onWarning?
optionalonWarning?: (w) =>void
Per-connection warning subscriber. Called for every framing warning on every connection.
Parameters
w
Returns
void
tls?
optionaltls?: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
readonlyacceptedTotal:number
Total connections accepted since listen() (monotonically increasing).
activeConnections
readonlyactiveConnections:number
Current live connection count. Same value as connections.
closedTotal
readonlyclosedTotal:number
Total connections closed since listen() (monotonically increasing).
connections
readonlyconnections:number
Current live connection count.
Same value as activeConnections.
host
readonlyhost:string|null
Bound host, or null before listen().
listening
readonlylistening:boolean
Whether the server is currently accepting connections.
port
readonlyport:number|null
Bound port, or null before listen().
tls
readonlytls:boolean
Whether this server is configured for TLS. Mirrors ServerOptions.tls being set.
tlsClientErrorsTotal
readonlytlsClientErrorsTotal:number
Total 'tlsClientError' events (failed TLS handshakes, incl. rejected client certs) since listen().
totalBytesIn
readonlytotalBytesIn:number
Aggregate bytes received across all current connections.
totalBytesOut
readonlytotalBytesOut: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?
readonlyoptionalca?:PemInput
Trust anchor(s) for verifying client certificates under WANT/MUST.
cert
readonlycert:PemInput
Server certificate (PEM). Required.
ciphers?
readonlyoptionalciphers?:string
OpenSSL cipher-list string passthrough. Unset uses Node's defaults (includes both ATNA-mandated ECDHE suites).
Default
undefined (Node defaults)
clientAuth?
readonlyoptionalclientAuth?: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
readonlykey:PemInput
Private key matching ServerTlsOptions.cert. Required.
maxVersion?
readonlyoptionalmaxVersion?:"TLSv1.2"|"TLSv1.3"
Maximum negotiated TLS protocol version.
minVersion?
readonlyoptionalminVersion?:"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?
readonlyoptionalpassphrase?: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?
readonlyoptionalackTimeoutMs?:number
Override default 30_000.
autoReconnect?
readonlyoptionalautoReconnect?:boolean
Override default true (auto-reconnect on transient errors).
correlateByControlId?
readonlyoptionalcorrelateByControlId?:boolean
Override default false (FIFO mode).
deadPeerTimeoutMs?
readonlyoptionaldeadPeerTimeoutMs?:number
Application-idle dead-peer timeout ms.
drainTimeoutMs?
readonlyoptionaldrainTimeoutMs?:number
Drain timeout for close() (default 30_000).
framing?
readonlyoptionalframing?:Omit<FrameReaderOptions,"onFrame"|"onWarning">
FrameReader options (passthrough).
handleSignals?
readonlyoptionalhandleSignals?: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?
readonlyoptionalhighWaterMark?:HighWaterMark
Override default 64.
host
readonlyhost:string
Host to connect to.
keepaliveIntervalMs?
readonlyoptionalkeepaliveIntervalMs?:number
TCP keepalive interval ms.
onBackpressure?
readonlyoptionalonBackpressure?:"reject"|"wait"
Override default 'reject'.
onMessage?
readonlyoptionalonMessage?: (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?
readonlyoptionalpipeline?:boolean
Override default true (parallel up to highWaterMark).
port
readonlyport:number
TCP port.
retryStrategy?
readonlyoptionalretryStrategy?:RetryStrategy
Custom reconnect-backoff hook.
tls?
readonlyoptionaltls?: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?
optionalallowWildcardBind?: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?
optionalautoAck?:"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
onMessagehandler ⇒ commit-gated (recommended). The server awaitsonMessage(the durable-commit step), then sendsAAon success or a negative ACK on failure (AEby default;ARvia MllpAckError). The positive ACK cannot precede a successful commit, a handler throw can never yieldAA. - Without an
onMessagehandler ⇒ transport-accept.AAis sent on frame receipt. ThisAAmeans only "bytes received and framed", not "application-processed". ⚠️ For clinical messages this is unsafe on its own: pair'AA'with anonMessagehandler that durably commits, so the ACK reflects real processing.
- With an
fn,fn(payload, meta, conn)builds the ACK bytes the server sends; the caller fully owns MSA-1 (e.g. to emit enhanced-modeCA/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
deadPeerTimeoutMs?
optionaldeadPeerTimeoutMs?: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?
optionaldrainTimeoutMs?:number
Graceful drain timeout passed to conn.close() during server.close().
Default: 30 000 ms.
Inherited from
framing?
optionalframing?: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
handleSignals?
optionalhandleSignals?: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?
optionalhost?: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?
optionalkeepaliveIntervalMs?: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?
optionalonMessage?: (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(orARvia MllpAckError), a positive ACK can never precede a successful commit. Do not callconn.send()here in this mode.autoAckunset ⇒ manual mode. The handler owns the response; build and send the ACK yourself viaconn.send(encodeFrame(ackPayload)). Its return value is ignored.autoAck: fn⇒ observation only.fnbuilds the ACK; this handler runs first as a side effect and its return value is ignored. Do not callconn.send()here.
May be sync or async; an async handler is awaited in commit-gated mode.
Parameters
payload
Buffer
meta
conn
Returns
void | Promise<void>
Inherited from
onWarning?
optionalonWarning?: (w) =>void
Per-connection warning subscriber. Called for every framing warning on every connection.
Parameters
w
Returns
void
Inherited from
port
port:
number
Port to listen on.
tls?
optionaltls?: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
StateChangeEvent
Payload for the 'stateChange' event. Always Object.freeze'd.
Example
conn.on('stateChange', ({ from, to, reason }) => {
logger.info({ from, to, reason });
});
Properties
from
readonlyfrom:ConnectionState
reason?
readonlyoptionalreason?:string
to
readonlyto: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?
readonlyoptionalallowUnverified?: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?
readonlyoptionalca?:PemInput
Trust anchor(s) for verifying the server's certificate chain.
cert?
readonlyoptionalcert?:PemInput
Client certificate presented for mutual TLS (ATNA ITI-19).
ciphers?
readonlyoptionalciphers?: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?
readonlyoptionalkey?:PemInput
Private key matching TlsOptions.cert.
maxVersion?
readonlyoptionalmaxVersion?:"TLSv1.2"|"TLSv1.3"
Maximum negotiated TLS protocol version.
minVersion?
readonlyoptionalminVersion?:"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?
readonlyoptionalpassphrase?:string
Passphrase for an encrypted TlsOptions.key.
servername?
readonlyoptionalservername?: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):
AAaccept,AEapplication error,ARapplication reject. The single ACK reports application-level outcome. - Enhanced mode (§2.9.3):
CAcommit accept,CEcommit error,CRcommit 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 byisTransientConnectionError, 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, …). SeeisTlsProtocolErrorfor the boundary. Pure TCP-level failures (ECONNREFUSED,ETIMEDOUT, …) on a TLS-configured connection carry noconnectionCause, 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-gatedonMessagehandler 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 readableMSH, an empty MSH-10, or a batch/concatenated-message shape). A positiveAAhere names a control ID the sender cannot match → timeout → resend → duplicate clinical message. See rawAckUncorrelatable.'discarded-bytes', the decoder flaggedMLLP_TRAILING_BYTESfor this frame: a mid-payloadVTmade it discard accumulated bytes and deliver only the fragment after it. The clinical message was destroyed in transit; a positiveAAwould 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
Returns
number | null
SecurityWarningCode
SecurityWarningCode = typeof
MLLP_TLS_VERIFY_DISABLED| typeofMLLP_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
constMLLP_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
constMLLP_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
constVERSION:string="0.0.5"
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
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:
- Reading. Splitting a
!-delimited message on|yields ONE field, so every echoed field, including MSH-10, came back empty. The ACK went out asMSA|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. - 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 isID#Xwould be re-emitted under^~\&as the literalID#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
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
Returns
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
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: trueackTimeoutMs: 30_000correlateByControlId: false(FIFO mode, simplest mental model)pipeline: truehighWaterMark: 64onBackpressure: '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
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
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
byteOffset
number
message
string
Returns
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?
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):
codestartingERR_SSL_(Node's TLS alert codes, e.g.ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED, aclientAuth: '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).messagecontainingssloralert(/\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 usablecode, andMllpClientconsults 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_*andUNABLE_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 aclientAuth: 'MUST'server rejecting the client certificate recurs on every attempt. On TLS-configured connectionsMllpClientadditionally consults isTlsProtocolError, which also catchesEPROTO/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 withconnectionCause: 'framing-fatal') → permanent (false). The peer is not speaking MLLP, an HTTP probe, a health check, a wrong-port misconfiguration, or is emitting frames pastmaxFrameSizeBytes. 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 byretryStrategyand 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:
- No readable
MSH.readMshSegmentreturnsnull, e.g. aBOM/SP/TABbeforeMSH(which shares theMSH's segment line, soMSHheads no segment), or a bare fragment delivered after a mid-payloadVTdiscard (MLLP_TRAILING_BYTES). MSA-2 would be empty: an ACK that correlates to nothing. - Empty MSH-10. The
MSHis readable but carries no message control ID, so there is nothing to echo, againMSA|AA|with an empty MSA-2. - A batch or concatenated messages. An
FHS/BHS/BTS/FTSenvelope (§2.10.3) or a secondMSHin 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
Example
import { resolveNackCode, MllpAckError } from '@cosyte/mllp';
resolveNackCode(new Error('boom')); // 'AE'
resolveNackCode(new MllpAckError('nope', { ackCode: 'AR' })); // 'AR'