Receiving HL7v2 messages
Stand up a Glion MLLP server, register routes, run middleware, and respond with the right ACK.
MLLP is the default protocol for sending and receiving HL7v2 messages over TCP. Glion ships an out-of-the-box MLLP server framework that handles framing, parsing, dispatch, and acknowledgment construction — so handler code only deals with the business outcome of each message.
You can learn more about the runtime architecture for running MLLP in Glion in the MLLP runtime model.
Getting started
- Create a new app from the
mllp-serverexample. - Move into the new project folder,
my-mllp-server. - Run the
devscript —glion devlistens on127.0.0.1:2575and reloads on file changes.
npm create glion@latest my-mllp-server -- --example mllp-server
cd my-mllp-server
npm run devpnpm create glion my-mllp-server --example mllp-server
cd my-mllp-server
pnpm devyarn create glion my-mllp-server --example mllp-server
cd my-mllp-server
yarn devbun create glion my-mllp-server --example mllp-server
cd my-mllp-server
bun --bun run dev- In a second terminal, send a test
ADT^A01message withncto the MLLP server listening at127.0.0.1:2575.
printf '\x0bMSH|^~\&|SENDER|SENDER|GLION|NODE|20260510120000||ADT^A01|MSG001|P|2.5.1\rEVN||20260510120000\rPID|||123456^^^MRN||Doe^John\r\x1c\x0d' \
| nc 127.0.0.1 2575The terminal logs the inbound message and nc prints back the AA acknowledgment.
Congratulations, you have a working MLLP server! For a typed client with NAK handling, TLS, and Cloudflare Workers support, see Send an HL7v2 message.
Project structure
An MLLP server project looks like any other TypeScript Node project, with two Glion-specific files: a router (the app's entry point) and a config file the CLI reads at startup.
Two files do the work specific to Glion:
| File | |
|---|---|
glion.config.ts | Declares the entry module and the listener port. The CLI loads this file at startup and uses it to find your router. |
src/app.ts | The router. Its default export is the Mllp instance the runtime serves. Parser, middleware, and route registrations all live here (see Write the router). |
Everything else is standard Node tooling: package.json for dependencies and the dev / start scripts, tsconfig.json for TypeScript. The path under src/ is a convention, not a requirement — entry in glion.config.ts resolves any path you give it.
Manual installation
- Install dependencies.
npm install @glion/cli @glion/mllp @glion/mllp-ack @glion/hl7v2 @glion/ackpnpm add @glion/cli @glion/mllp @glion/mllp-ack @glion/hl7v2 @glion/ackyarn add @glion/cli @glion/mllp @glion/mllp-ack @glion/hl7v2 @glion/ackbun add @glion/cli @glion/mllp @glion/mllp-ack @glion/hl7v2 @glion/ack- Add the dev and start scripts.
{
"scripts": {
"dev": "glion dev",
"start": "glion start"
}
}glion dev runs the server in development mode with file-watching, hot reload, and the terminal UI. glion start runs the same server in production mode with structured logs and no watcher.
- Create the configuration at
glion.config.ts.
glion.config.ts at the project root is the single source of truth for the server. The CLI loads it, resolves entry to your router module, and starts the listener on the configured port.
import { defineConfig } from "@glion/cli/config";
export default defineConfig({
entry: "./src/app.ts",
port: 2575,
});- Create the router at
src/app.ts.
import { Mllp } from "@glion/mllp";
import { parseHL7v2 } from "@glion/hl7v2";
import { ackMiddleware } from "@glion/mllp-ack";
const app = new Mllp().parser(parseHL7v2);
app.use(ackMiddleware());
app.on("ADT^A01", (ctx) => {
console.log(`Patient admit ${ctx.controlId}`);
});
export default app;The four pieces:
parser(parseHL7v2)— wires the default HL7v2 parser.app.use(ackMiddleware())— registers the middleware that turns handler outcomes into wire ACKs (see Middleware).app.on("ADT^A01", ...)— registers a handler for one trigger event (see Routing).export default app— the CLI imports this from yourentrypath.
- Run the server.
Run npm run dev (or your package manager's equivalent) and the server is live on 127.0.0.1:2575.
Middleware
Use middleware for cross-cutting concerns like logging, metrics, authentication, or rate-limiting. Glion also ships built-in middleware such as ackMiddleware() from @glion/mllp-ack.
Middleware wraps handlers using the onion pattern: each middleware runs before await next(), hands control downward to the next layer, then runs again after next() resolves. They register in declaration order with app.use(fn), so a logging middleware registered first sees every message before validation has rejected it; a metrics middleware registered last sees only messages that reached a handler.
Custom middleware
You can also create your own custom middleware. In the example below, the middleware times every message and logs success or failure once the handler completes:
import { Mllp } from "@glion/mllp";
const app = new Mllp();
app.use(async (ctx, next) => {
const start = Date.now();
try {
// ... pre-processing logic like auth checks or rate-limiting...
await next(); // Pass control to the next middleware or handler.
// ... post-processing logic like logging or metrics...
ctx.log.info(`ok ${Date.now() - start}ms ${ctx.controlId}`);
} catch (error) {
ctx.log.error(`fail ${Date.now() - start}ms ${String(error)}`);
throw error;
}
});
app.on("*", (ctx) => {
console.log(`HL7v2 message received ${ctx.controlId}`);
});
export default app;Good to know
A middleware can short-circuit by calling ctx.respond(ack) and returning instead of awaiting next() — useful for rate-limiters, circuit breakers, or auth checks that should respond AR before any downstream work runs.
Routing
Glion's MLLP server uses a route-based model to dispatch messages to handlers.
import { Mllp } from "@glion/mllp";
import { parseHL7v2 } from "@glion/hl7v2";
import { ackMiddleware } from "@glion/mllp-ack";
import { UnsupportedMessageTypeReject } from "@glion/ack";
const app = new Mllp().parser(parseHL7v2);
app.use(ackMiddleware());
app.on("ADT^A01", handler); // exact match
app.on("ADT^*", handler); // any ADT trigger event
app.on("*^A01", handler); // any message type with the A01 event
app.on("ADT", handler); // any ADT (same as ADT^*)
app.on("*", handler); // catch-all
export default app;The pattern shapes the router accepts:
| Pattern | Matches |
|---|---|
"ADT^A01" | Exactly that trigger event. |
"ADT^*" | Any trigger event under the ADT message type. |
"*^A01" | Any message type with the A01 trigger event. |
"ADT" | Any ADT message, regardless of trigger event. |
"*" | Catch-all — runs only if nothing else matched. |
When several patterns could match the same message, the first registered route wins — register specific routes before catch-alls.
Custom routing
For dispatch logic that doesn't map to trigger events — routing by sending facility, by partner ID, or by message version — app.on also accepts a custom matcher function instead of a pattern string. The matcher receives the context and returns a boolean for whether the handler should run.
import { Mllp } from "@glion/mllp";
import { parseHL7v2 } from "@glion/hl7v2";
const app = new Mllp();
app.parser(parseHL7v2);
const matcher = (ctx) => ctx.message.get("MSH-4") === "SOME_FACILITY";
app.on(matcher, (ctx) => {
const facility = ctx.message.get("MSH-4");
return handlers[facility](ctx);
});
export default appHandlers
Handlers are where your business logic lives. They run after the middleware chain when a route matches.
A handler is an async function — it can hit a database, call an API, emit a domain event. Handlers are agnostic to the transport and protocol; they only know about the message and the business logic. Each one receives a ctx object and signals its outcome by returning normally or throwing an exception.
The ctx exposes:
- the parsed message,
- the resolved profile, if one is attached,
- the source
vfile(with any diagnostics), - helpers like
ctx.respondandctx.log.
Good to know
If a message matches no handler, Glion sends back an AR rejection with a diagnostic that names the unrouted trigger event. The catch-all in the Routing example shows the explicit form.
app.on("ADT^A01", async (ctx) => {
const patientId = ctx.message.get("PID-3");
const patientName = ctx.message.get("PID-5");
// Business logic here, e.g. database calls, API requests, etc.
const results = await database.savePatientAdmit({ patientId, patientName });
// Returning normally produces an AA (Application Accept) ACK.
return results;
});Error handling
@glion/ack ships built-in exception types that map one-to-one to MSA-1 NAK codes. Throw one from a handler and ackMiddleware() reads its fields, translating them into the right MSA-1, MSA-3, ERR-3, and ERR-4 fields in the wire ACK. A plain throw new Error(...) still works — it becomes AE — but loses the diagnostic detail.
Standard exceptions
The following exceptions correspond one-to-one with the MSA-1 codes carried in an HL7v2 ACK message:
| Exception class | Code | When to throw |
|---|---|---|
AckApplicationError | AE | The message was understood but cannot be processed (validation, missing record, downstream timeout). |
AckApplicationReject | AR | The message is rejected outright at the application level (unknown partner, unsupported version, semantic invalidity). |
AckCommitError | CE | Enhanced mode — the message could not be persisted (database down, storage full). |
AckCommitReject | CR | Enhanced mode — the message will not be persisted (commit policy violation). |
Each exception accepts a message and an optional object with errorCode (ERR-3) and severity (ERR-4) for more detailed diagnostics in the ACK. For example:
import { AckApplicationError, Hl7ErrorCode, Severity } from "@glion/ack";
app.on("ORU^R01", (ctx) => {
if (!ctx.message.has("OBR")) {
throw new AckApplicationError("Missing OBR segment", {
errorCode: Hl7ErrorCode.RequiredFieldMissing,
severity: Severity.Error,
});
}
});Built-in exceptions
@glion/ack ships convenience subclasses of the standard exceptions, pre-filled with errorCode and severity for common cases.
| Exception class | Code | When to throw |
|---|---|---|
UnsupportedMessageTypeReject | AR | The message's trigger event is not supported by the receiver. Useful in catch-all routes. |
ApplicationInternalError | AE | A catch-all for unexpected errors — the message is understood but something went wrong processing it. |
For the full list of built-in exceptions, see the source code.
Custom exceptions
Extend the standard exceptions to capture domain-specific failures while keeping the right NAK code and diagnostics on the wire:
import { AckApplicationError, Hl7ErrorCode, Severity } from "@glion/ack";
export class PatientNotFoundError extends AckApplicationError {
constructor(mrn: string) {
super(`Patient not found: ${mrn}`, {
errorCode: Hl7ErrorCode.UnknownKeyIdentifier,
severity: Severity.Error,
});
this.name = "PatientNotFoundError";
}
}Throw PatientNotFoundError from your handlers and the client receives an AE with MSA-3 set to Patient not found: <mrn> and ERR-3 set to UnknownKeyIdentifier.
import { Mllp } from "@glion/mllp";
import { parseHL7v2 } from "@glion/hl7v2";
import { ackMiddleware } from "@glion/mllp-ack";
import { PatientNotFoundError } from "./errors";
const app = new Mllp();
app.parser(parseHL7v2);
app.use(ackMiddleware());
app.on("ADT^A01", (ctx) => {
const mrn = ctx.message.get("PID-3");
const patient = database.findPatientByMrn(mrn);
if (!patient) {
throw new PatientNotFoundError(mrn);
}
});
export default app;Acknowledgments
HL7v2 acknowledgments (ACKs) are the standard way for a receiver and a sender to communicate about the processing outcome of a message. An ACK is an HL7v2 message that the receiver sends back to the sender over MLLP after processing an inbound message. By default, a receiver must wait for an ACK response before confirming receipt of the message. This is called "synchronous ACK mode" and is the most common pattern for MLLP integrations. For more on the ACK message itself, see Acknowledgments.
Glion MLLP server provides a middleware, ackMiddleware(), that automatically turns handler response into the right ACK response on the wire. This way, the handler only needs to return its business outcome or throw exceptions — it never constructs ACK strings or sets MSA-1 codes directly. The middleware also fills MSA-2 (control id), MSA-3 (textual reason), and the optional ERR segment from the exception fields. Application code never constructs ACK strings to make it fully compliant with the HL7v2 standard and to ensure that ACKs always reflect the true processing outcome of each message.
The basic mapping from handler outcome to MSA-1 ACK code is:
| Handler outcome | ACK code |
|---|---|
Returns normally, no fatal vfile diagnostics | AA |
ctx.respond(ack) called explicitly | The supplied ACK |
Throws AckApplicationError (or a subclass) | AE |
Throws AckApplicationReject | AR |
Throws AckCommitError | CE |
Throws AckCommitReject | CR |
| Throws any other error | AE |
See also
- The MLLP runtime model — how the listener, framer, parser, dispatcher, and middleware compose.
- Sending HL7v2 messages — the matching client side, with NAK handling and TLS.
- Acknowledgments — what
AA,AE, andARmean in the HL7v2 standard.
Processing HL7v2 messages
Run a message through Glion's processing pipeline — parse, decode escapes, annotate against a profile, and lint — and read back the annotated tree plus diagnostics.
Sending HL7v2 messages
Open an MLLP connection, send an HL7v2 message, read the ACK response, and process exceptions.