Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/what-0-1-means.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@cosyte/mllp": minor
---

`0.1.0` is the first release of `@cosyte/mllp` whose public API we ask you to build on.

**What is covered, and what you can depend on.** Sending and receiving HL7 v2 over MLLP: the client and the server (`createClient`, `createServer` and their starter forms), strict framing through `encodeFrame` and `FrameReader`, acknowledgement correlation on MSA-2, and the commit contract, under which the server's positive acknowledgement never comes before your handler's commit and a handler that throws is answered `AE`. Auto-reconnect with backoff, backpressure, a `close()` that drains and then tells a message that was never written apart from one whose fate is unknown, TLS with certificate verification on by default, and a server that binds loopback unless you opt in to more. The socket-free `InMemoryTransport` at `@cosyte/mllp/testing`, acknowledgements built from a parsed message at `@cosyte/mllp/ack-from-hl7` (with the optional `@cosyte/hl7` peer), and `runDifferential`, which reports how the engine you point it at frames and acknowledges. The stable warning codes and security-warning codes are part of this API. Node.js 22 and 24, ESM and CommonJS, with type declarations for both.

**What the version promises.** Until 1.0, a breaking change raises the minor version (0.1 to 0.2), and the changelog entry says what broke and what to change. A patch release (0.1.x) does not break you, so a `^0.1.0` range takes the patches and stops before 0.2.0.

**What is not covered yet.** Batch acknowledgement: an `FHS`/`BHS` envelope is answered with a warned `AE`, never a positive one. MLLP Release 2. A queue or replay store for unacknowledged messages. Differential coverage of engines other than the freely available ones it runs against, so Epic and Cerner are not in it. The documentation at https://docs.cosyte.com/mllp lists the known limitations in full.

The repository now carries runnable examples under `examples/`, run against the built package on every change.
25 changes: 25 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,28 @@ jobs:
uses: cosyte/.github/.github/workflows/ci.yml@main
with:
run-phi-scan: true

# The programs under examples/, run against the BUILT package the way a consumer imports it:
# `pnpm examples` (scripts/run-examples.ts) fails when any example exits non-zero, and each
# example checks its own output. Added on top of the shared caller above, never instead of it.
# `examples` is a new check-run context and deliberately not a required one yet: let it run on
# main first, then read the real names off a live check run before adding them to a ruleset.
examples:
strategy:
fail-fast: false
matrix:
node: ["22", "24"]
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: ${{ matrix.node }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Build (the examples import the built package)
run: pnpm build
- name: Run every example
run: pnpm examples
2 changes: 2 additions & 0 deletions documentation/release-0.1.0-readiness.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ documentation of what already ships and contributor-only tooling, stays `patch`.
| `sweet-pandas-classify.md` | not present, added by the change that wrote this record | patch | "No runtime, API or published behaviour changes: this is release bookkeeping." |
| `brave-moons-withdraw.md` | present, unlisted when this table was written | patch | "No runtime behaviour of this package changes. The framing, ACK, warning-code and TLS surfaces are untouched." |
| `plain-owls-supply.md` | not present, added by the Diffie-Hellman parameter change | **minor** | "**`tls.dhParameters` on `ServerTlsOptions`, PEM content, server side only.**" |
| `first-use-examples-executed.md` | present, unlisted when the examples change found it | patch | "Docs: the README's usage example is now one complete program the test suite runs, and the quickstart's first example is held to a committed synthetic fixture." |
| `what-0-1-means.md` | not present, added by the change that added `examples/` and the release metadata | **minor** | "`0.1.0` is the first release of `@cosyte/mllp` whose public API we ask you to build on." It adds no symbol; it is the release's own statement of what the `0.1` line covers and promises, and it moves nothing, since the minor rows above already take the release to `0.1.0`. |

**Only the frontmatter bump-type line moved.** Every summary is byte-identical to what it was, and
the diff for the five corrected files is one line each. A summary is the changelog entry for its
Expand Down
10 changes: 9 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@ import cosyte from "@cosyte/eslint-config";

export default [
...cosyte(import.meta.dirname, {
ignores: ["examples/**"],
files: ["src/**/*.ts", "test/**/*.ts", "scripts/**/*.ts", "examples/**/*.ts", "*.config.ts"],
}),

// The examples are programs a reader runs: printing what they show is their job.
{
files: ["examples/**/*.ts"],
rules: {
"no-console": "off",
},
},

// no-buffer-slice, Buffer.prototype.slice() copies the underlying ArrayBuffer in modern Node;
// use .subarray() for zero-copy. Scoped to the byte-handling paths (repo guardrail, stays an error).
{
Expand Down
27 changes: 27 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Examples

Four small programs that show what `@cosyte/mllp` does, each runnable as it stands. Every example
imports `@cosyte/mllp` by its published name, which resolves through this package's own `exports` to
the built `dist/`, so build first. Each one checks its own output and exits non-zero on a mismatch.

```bash
pnpm install
pnpm build
pnpm examples # runs all four
pnpm tsx examples/send-and-acknowledge.ts # or one at a time
```

| Example | What it shows |
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [`send-and-acknowledge.ts`](./send-and-acknowledge.ts) | A server and a client on loopback. The auto-ACK waits for your commit handler: `AA` when it resolves, `AE` when it throws, and MSA-2 echoes the control id either way. |
| [`frame-and-unframe.ts`](./frame-and-unframe.ts) | `encodeFrame` and `FrameReader` with no socket: the canonical `VT + payload + FS + CR` block, a frame split across two reads, and a deviation refused by default and reported by code when opted into. |
| [`test-without-a-socket.ts`](./test-without-a-socket.ts) | A `Connection` driven over `InMemoryTransport.pair()` from `@cosyte/mllp/testing`, against a test double that reads one byte at a time and answers `AA`. |
| [`verify-an-engine.ts`](./verify-an-engine.ts) | `runDifferential` aimed at an endpoint, here this package's own server on loopback, and the per-exchange frame-parity and MSA-2 report it returns. |

Every message here is synthetic: an ADT^A01 header with placeholder application and facility names
and a made-up control id, and no patient segment. The differential harness sends its own corpus of
synthetic messages; aim it only at a test or staging endpoint you own.

`tsconfig.json` in this folder maps `@cosyte/mllp` to the source, so `pnpm typecheck` and `pnpm lint`
check the examples before anything is built. At run time nothing maps the name: Node resolves it
through `exports`, as it does in your project.
63 changes: 63 additions & 0 deletions examples/frame-and-unframe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Frame and unframe MLLP bytes without a socket.
*
* `encodeFrame` always emits the canonical block, `VT + payload + FS + CR`. `FrameReader` is a
* stateful decoder: feed it whatever TCP hands you and each complete frame fires on `onFrame`. It
* is strict by default, and each tolerance is a flag you turn on, reported with a stable warning
* code and the byte offset it was found at.
*
* Run it after `pnpm build`:
*
* pnpm tsx examples/frame-and-unframe.ts
*/

import assert from "node:assert/strict";

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

// A synthetic ADT^A01 header with placeholder applications and facilities, and no patient segment.
const payload = Buffer.from(
"MSH|^~\\&|SENDING_APP|SENDING_FAC|RECEIVING_APP|RECEIVING_FAC|20260101120000||ADT^A01|CTRL0001|P|2.5.1\r",
);

const frame = encodeFrame(payload);
console.log(`frame ${String(frame.length)} bytes, payload ${String(payload.length)} bytes`);
assert.equal(frame.length, payload.length + 3, "three framing bytes: VT, FS, CR");
assert.equal(frame[0], 0x0b, "the block opens with VT");
assert.deepEqual([...frame.subarray(-2)], [0x1c, 0x0d], "the block closes with FS CR");

// A frame split across two reads, the way TCP delivers it.
const frames: Buffer[] = [];
const reader = new FrameReader({ onFrame: (bytes) => frames.push(Buffer.from(bytes)) });
reader.push(frame.subarray(0, 10));
assert.equal(frames.length, 0, "a partial frame fires nothing");
reader.push(frame.subarray(10));
console.log(`frames decoded after two reads: ${String(frames.length)}`);
assert.equal(frames.length, 1);
assert.ok(frames[0]?.equals(payload), "the decoded payload is byte-identical to what was framed");

// A peer that closes its frames with FS LF instead of FS CR.
const lfClosed = Buffer.concat([frame.subarray(0, -1), Buffer.from([0x0a])]);

// Strict by default: the deviation is refused, with a stable code.
const strict = new FrameReader({ onFrame: () => undefined });
assert.throws(
() => {
strict.push(lfClosed);
},
(error: unknown) => error instanceof MllpFramingError && error.code === "MLLP_LF_AFTER_FS",
);
console.log("strict reader refused FS LF: MLLP_LF_AFTER_FS");

// Opted in: the frame is delivered, and the deviation is reported rather than hidden.
const tolerated: MllpWarning[] = [];
const lenient = new FrameReader({
allowLfAfterFs: true,
onFrame: (_bytes, _byteOffset, warnings) => tolerated.push(...warnings),
});
lenient.push(lfClosed);
console.log(`lenient reader warned: ${tolerated.map((w) => w.code).join(", ")}`);
assert.deepEqual(
tolerated.map((w) => w.code),
["MLLP_LF_AFTER_FS"],
);
65 changes: 65 additions & 0 deletions examples/send-and-acknowledge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Send HL7 v2 messages over a real loopback socket and read the acknowledgement for each.
*
* The server's auto-ACK awaits `onMessage`, your durable commit step, before it answers. A handler
* that resolves earns `AA`. A handler that throws earns `AE`, so the sender is never told to forget
* a message you did not keep.
*
* Run it after `pnpm build`:
*
* pnpm tsx examples/send-and-acknowledge.ts
*/

import assert from "node:assert/strict";

import { createStarterClient, createStarterServer } from "@cosyte/mllp";

/** A synthetic ADT^A01 header with placeholder applications and facilities, and no patient segment. */
function adtHeader(controlId: string): Buffer {
return Buffer.from(
`MSH|^~\\&|SENDING_APP|SENDING_FAC|RECEIVING_APP|RECEIVING_FAC|20260101120000||ADT^A01|${controlId}|P|2.5.1\r`,
);
}

/** MSA-1 (the acknowledgement code) and MSA-2 (the control id it answers), nothing else. */
function readMsa(ack: Buffer): { code: string | undefined; controlId: string | undefined } {
const msa = ack
.toString("latin1")
.split("\r")
.find((segment) => segment.startsWith("MSA|"))
?.split("|");
return { code: msa?.[1], controlId: msa?.[2] };
}

const committed: Buffer[] = [];

// Port 0: the operating system picks a free port. The host defaults to loopback, 127.0.0.1.
const server = await createStarterServer({
port: 0,
onMessage: async (payload) => {
// Stand-in for your durable commit. One control id simulates a write that failed.
if (payload.includes("CTRL0002")) throw new Error("the commit failed");
committed.push(payload);
return Promise.resolve();
},
});

const client = await createStarterClient({ host: "127.0.0.1", port: server.getStats().port ?? 0 });

try {
const kept = readMsa(await client.send(adtHeader("CTRL0001")));
console.log(`CTRL0001 answered ${String(kept.code)}, MSA-2 ${String(kept.controlId)}`);
assert.equal(kept.code, "AA", "a committed message is acknowledged AA");
assert.equal(kept.controlId, "CTRL0001", "MSA-2 echoes the control id sent");

const lost = readMsa(await client.send(adtHeader("CTRL0002")));
console.log(`CTRL0002 answered ${String(lost.code)}, MSA-2 ${String(lost.controlId)}`);
assert.equal(lost.code, "AE", "a message whose commit threw is never acknowledged AA");
assert.equal(lost.controlId, "CTRL0002", "MSA-2 echoes the control id sent");

console.log(`messages committed: ${String(committed.length)}`);
assert.equal(committed.length, 1);
} finally {
await client.close();
await server.close();
}
66 changes: 66 additions & 0 deletions examples/test-without-a-socket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Drive a whole `Connection` over the in-memory transport: no port, no certificate, no timers.
*
* `InMemoryTransport.pair()` returns two connected ends. A write to one end is delivered to the
* other synchronously, so a test runs in-process and deterministically. Here the far end is a test
* double that answers every message with a positive acknowledgement built from it, and it reads
* one byte at a time, the worst a real socket can do.
*
* Run it after `pnpm build`:
*
* pnpm tsx examples/test-without-a-socket.ts
*/

import assert from "node:assert/strict";

import { buildRawAck, Connection, encodeFrame, FrameReader } from "@cosyte/mllp";
import { InMemoryTransport } from "@cosyte/mllp/testing";

// A synthetic ADT^A01 header with placeholder applications and facilities, and no patient segment.
const message = Buffer.from(
"MSH|^~\\&|SENDING_APP|SENDING_FAC|RECEIVING_APP|RECEIVING_FAC|20260101120000||ADT^A01|CTRL0001|P|2.5.1\r",
);

const [ourSide, peerSide] = InMemoryTransport.pair();

// The test double: reassemble frames, answer each one with AA.
peerSide.split(1);
const peerReader = new FrameReader({
onFrame: (payload) => {
peerSide.write(encodeFrame(buildRawAck(payload, "AA")));
},
});
peerSide.onData((chunk) => {
peerReader.push(chunk);
});

const acks: Buffer[] = [];
const connection = new Connection({
transport: ourSide,
onMessage: (payload) => acks.push(Buffer.from(payload)),
});
connection.notifyConnect(null, null);
console.log(`state: ${connection.state}`);
assert.equal(connection.state, "CONNECTED");

connection.send(encodeFrame(message));

// Already answered, with nothing awaited.
const msa = acks[0]
?.toString("latin1")
.split("\r")
.find((segment) => segment.startsWith("MSA|"))
?.split("|");
console.log(`acknowledgements received: ${String(acks.length)}`);
console.log(`MSA-1 ${String(msa?.[1])}, MSA-2 ${String(msa?.[2])}`);
assert.equal(acks.length, 1);
assert.equal(msa?.[1], "AA");
assert.equal(msa?.[2], "CTRL0001", "MSA-2 echoes the control id sent");

const stats = connection.getStats();
console.log(`bytes out ${String(stats.bytesOut)}, bytes in ${String(stats.bytesIn)}`);
assert.equal(stats.bytesOut, message.length + 3);

connection.destroy();
console.log(`state: ${connection.state}`);
assert.equal(connection.state, "CLOSED");
11 changes: 11 additions & 0 deletions examples/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"paths": {
"@cosyte/mllp": ["../src/index.ts"],
"@cosyte/mllp/testing": ["../src/testing/index.ts"],
"@cosyte/mllp/ack-from-hl7": ["../src/ack-from-hl7/index.ts"]
}
},
"include": ["*.ts"]
}
48 changes: 48 additions & 0 deletions examples/verify-an-engine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Run the differential harness against an MLLP endpoint and read its report.
*
* `runDifferential` sends a small corpus of synthetic messages to the peer you name, one connection
* per message, and reports for each exchange whether the frame that came back was the canonical
* block and whether its MSA-2 echoed the control id sent. The report carries codes, offsets and
* counts, never message content. It is an observation, not a verdict.
*
* The peer here is this package's own server on loopback, standing in for your engine. The corpus
* lands in whatever the endpoint feeds, so aim it at a test or staging endpoint you own, never at a
* production interface.
*
* Run it after `pnpm build`:
*
* pnpm tsx examples/verify-an-engine.ts
*/

import assert from "node:assert/strict";

import { createStarterServer, runDifferential } from "@cosyte/mllp";

const engine = await createStarterServer({
port: 0,
onMessage: async () => Promise.resolve(),
});

try {
const report = await runDifferential({
peer: `127.0.0.1:${String(engine.getStats().port ?? 0)}`,
});

console.log(`result: ${report.result}`);
for (const exchange of report.exchanges) {
console.log(
`${exchange.exchangeId}: ${exchange.outcome}, parity ${exchange.byteParity}, MSA-2 ${exchange.correlation}`,
);
}

assert.equal(report.result, "parity-observed");
assert.ok(report.exchangesAttempted > 0, "the harness sent something");
assert.equal(report.exchangesAnswered, report.exchangesAttempted);
for (const exchange of report.exchanges) {
assert.equal(exchange.byteParity, "match", `${exchange.exchangeId} frame parity`);
assert.equal(exchange.correlation, "match", `${exchange.exchangeId} MSA-2 correlation`);
}
} finally {
await engine.close();
}
Loading
Loading