@cosyte/mllp 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +529 -0
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/dist/ack-from-hl7/index.cjs +417 -0
- package/dist/ack-from-hl7/index.cjs.map +1 -0
- package/dist/ack-from-hl7/index.d.cts +425 -0
- package/dist/ack-from-hl7/index.d.ts +425 -0
- package/dist/ack-from-hl7/index.mjs +400 -0
- package/dist/ack-from-hl7/index.mjs.map +1 -0
- package/dist/index-DjGQJtYE.d.cts +83 -0
- package/dist/index-DjGQJtYE.d.ts +83 -0
- package/dist/index.cjs +3455 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3112 -0
- package/dist/index.d.ts +3112 -0
- package/dist/index.mjs +3428 -0
- package/dist/index.mjs.map +1 -0
- package/dist/testing/index.cjs +243 -0
- package/dist/testing/index.cjs.map +1 -0
- package/dist/testing/index.d.cts +174 -0
- package/dist/testing/index.d.ts +174 -0
- package/dist/testing/index.mjs +241 -0
- package/dist/testing/index.mjs.map +1 -0
- package/package.json +124 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
Versions and publishing are managed with [Changesets](https://github.com/changesets/changesets);
|
|
8
|
+
this file is maintained by hand (Changesets handles the version bump and publish only).
|
|
9
|
+
|
|
10
|
+
## [Unreleased]
|
|
11
|
+
|
|
12
|
+
The first pre-alpha release (`0.0.1`) will ship the v1 MLLP transport surface below. The package
|
|
13
|
+
begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` until first alpha).
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **Removed ten orphan gitlinks from `.claude/worktrees/`.** A commit captured local agent worktree
|
|
18
|
+
state as ten mode-`160000` gitlinks with no `.gitmodules` entry, pointing at objects that never
|
|
19
|
+
existed in this repo. This is the ADR 0004 failure mode that `iac` and `pathways` each produced;
|
|
20
|
+
it went unnoticed here. `.claude/worktrees/` is now gitignored so it cannot recur. Repo hygiene
|
|
21
|
+
only — nothing in `src/`, `test/`, or the published tarball is affected.
|
|
22
|
+
|
|
23
|
+
- **The Release workflow can actually start.** `.github/workflows/release.yml` calls the shared
|
|
24
|
+
`cosyte/.github` pipeline, which requests `contents`/`id-token`/`pull-requests: write`, but declared
|
|
25
|
+
no `permissions:` of its own — so it inherited the repo default of `contents: read`. A called
|
|
26
|
+
workflow may only downgrade the caller's `GITHUB_TOKEN`, never escalate it, so GitHub rejected the
|
|
27
|
+
workflow at startup (~1s, no jobs, no logs). Every Release run from June 2026 until now failed this
|
|
28
|
+
way, unnoticed, because a `startup_failure` produces no logs to read. The caller job now declares
|
|
29
|
+
the three scopes explicitly. CI-only — no runtime or API change.
|
|
30
|
+
|
|
31
|
+
- **`buildRawAck` and the server's auto-ACK path said "AA — I've got it" for messages they could not
|
|
32
|
+
correlate (MLLP-ACK-FAILSAFE).** A positive acknowledgement tells the sender it may forget the
|
|
33
|
+
message; when the ACK names a control ID the sender cannot match — or names one of several messages
|
|
34
|
+
it never read — the sender times out and resends, committing a **duplicate clinical message** (or
|
|
35
|
+
believes a destroyed message was delivered). `buildMllpAck` already downgraded and warned; the raw
|
|
36
|
+
builder and the default `autoAck: 'AA'` path did not, and four peer-reachable inputs — all
|
|
37
|
+
pre-existing on `main` — produced a positive `MSA|AA|`: (1) an inbound with an empty MSH-10, (2)
|
|
38
|
+
**two concatenated `MSH` messages** in one frame (an `AA` naming only the first, message 2 silently
|
|
39
|
+
unacknowledged), (3) a **`BOM`/`SP`/`TAB` before `MSH`** (the junk shares the MSH's segment line, so
|
|
40
|
+
`MSH` heads no segment → unreadable → `MSA|AA|` with an empty MSA-2, no warning), and (4) worst,
|
|
41
|
+
verified over a real socket, a **raw `VT` inside a payload** — the decoder discards the accumulated
|
|
42
|
+
bytes (`MLLP_TRAILING_BYTES`) and delivers only the *fragment* after it, which the server auto-ACKed
|
|
43
|
+
`MSA|AA|`: the clinical message destroyed and positively acknowledged. A requested positive code
|
|
44
|
+
(`AA`/`CA`) is now **downgraded** to `AE`/`CE` whenever the payload cannot carry a correlatable
|
|
45
|
+
positive ACK — no readable `MSH`, an empty MSH-10, a batch/concatenated-message shape
|
|
46
|
+
(`FHS`/`BHS`/`BTS`/`FTS` or a second `MSH`), or, on the server path, a frame the decoder flagged
|
|
47
|
+
with discarded bytes. This is a **refusal**, not a widened reader: it never makes an unreadable
|
|
48
|
+
message readable, re-bases on a located `MSH`, or parses a batch — batch ACK stays its own unbuilt
|
|
49
|
+
feature (`MLLP-BATCH`), a loud non-positive answer. The wire downgrade in `buildRawAck` protects any
|
|
50
|
+
direct caller (defense in depth); the server re-checks the same condition so the downgrade is
|
|
51
|
+
**observable**, emitting a PHI-safe `'nack'` event with a new `reason`
|
|
52
|
+
(`'handler-rejected' | 'uncorrelatable-inbound' | 'discarded-bytes'`), never the payload or control
|
|
53
|
+
ID. New exports: `rawAckUncorrelatable(payload)` and the `NackReason` type. As part of this,
|
|
54
|
+
`MLLP_TRAILING_BYTES` is now **reserved for the mid-payload `VT` discard** (a frame-scoped signal)
|
|
55
|
+
and is no longer emitted — nor mis-attributed to the *next* frame — for an inter-frame stray byte
|
|
56
|
+
under `allowFsOnly`, which `MLLP_FS_WITHOUT_CR` already reports; without that, a good message
|
|
57
|
+
pipelined after a stray-byte frame would have been wrongly downgraded to `AE` (caught by the
|
|
58
|
+
conformance gate).
|
|
59
|
+
- **The MSH-10 scan ran past the segment terminator and returned the patient's MRN as the
|
|
60
|
+
correlation key (MLLP-ACK-UTF8; found by the conformance gate).** `extractMshControlId` counted
|
|
61
|
+
field separators without ever stopping at `CR`/`LF`. On a **truncated MSH** — one with fewer than
|
|
62
|
+
10 fields, which is malformed, but is precisely what a broken peer sends — the count therefore ran
|
|
63
|
+
*past the segment terminator* and kept counting inside the next segment. Given
|
|
64
|
+
`MSH|^~\&|EPIC|HOSP|MIRTH|LAB` + `PID|1||MRN00042|…`, the "MSH-10" it returned was **`PID-3`: the
|
|
65
|
+
patient's medical record number**. `MllpClient.send()` calls this on every outbound payload in
|
|
66
|
+
controlId mode, so that value became the correlator's key, and was carried into
|
|
67
|
+
`MllpTimeoutError.messageControlId` and the `MLLP_ACK_UNMATCHED_CONTROL_ID` /
|
|
68
|
+
`MLLP_ACK_AFTER_TIMEOUT` warnings — **a patient identifier in a log line, and a mis-read one at
|
|
69
|
+
that**, plus a correlation key that is not the control ID the peer will ACK. Present since Phase 5
|
|
70
|
+
and untouched by MLLP-CORRELATOR-ASCII, which fixed the *decode* of this scan but not its
|
|
71
|
+
*bounds*. Fixed: the scan is now `readMshSegment`, which bounds the MSH at its terminator before
|
|
72
|
+
reading any field out of it. A field that does not exist reads as **absent**, never as the next
|
|
73
|
+
segment's contents.
|
|
74
|
+
- **`ack-from-hl7` could not echo a control ID verbatim, so a cosyte client could not correlate a
|
|
75
|
+
cosyte server's ACK (MLLP-ACK-UTF8).** `buildMllpAck` decoded the inbound through the peer
|
|
76
|
+
parser's charset machinery and re-encoded the ACK through a hardcoded **`utf8`**. The two are not
|
|
77
|
+
inverses. A control-ID byte `0x8B` — legal under an `MSH-18` of `8859/1`, and the exact case
|
|
78
|
+
`MLLP-CORRELATOR-ASCII` had just fixed on the client — came back out of MSA-2 as the **two** bytes
|
|
79
|
+
`0xC2 0x8B`: a *different* control ID, so HL7 v2.5.1 §2.9.2.2's verbatim-echo requirement was
|
|
80
|
+
violated. The client keys its in-flight store on the raw bytes it sent, so it could not match that
|
|
81
|
+
ACK: the send never settled → ACK timeout → resend → **duplicate clinical message**. This was the
|
|
82
|
+
third and last of the three call sites that each re-derived "read the control ID" independently and
|
|
83
|
+
each got it wrong differently.
|
|
84
|
+
- `Buffer` input is now decoded as **`latin1`** and the ACK re-encoded with the same codec — one
|
|
85
|
+
symmetric choice, so the round-trip is the exact identity for any inbound bytes. `latin1` is the
|
|
86
|
+
only codec for which that holds: `ascii` masks the high bit, `utf8` folds invalid sequences onto
|
|
87
|
+
`U+FFFD`, and a `TextDecoder`'s `iso-8859-1` label is aliased by the WHATWG Encoding Standard to
|
|
88
|
+
**windows-1252** (`0x8B` → `U+2039`), which does not round-trip `0x80`–`0x9F` at all — so the
|
|
89
|
+
decode had to be taken away from the charset-aware parser and done on the bytes directly.
|
|
90
|
+
`string` / `Hl7Message` input keeps its `utf8` default (the caller already chose the decode).
|
|
91
|
+
- The MSH read is now **one** implementation — `readMshSegment` in `src/internal/control-id.ts` —
|
|
92
|
+
genuinely *called* by all three consumers: the client correlator, `buildRawAck`, and
|
|
93
|
+
`buildMllpAck`. `buildRawAck` previously re-derived its own read
|
|
94
|
+
(`payload.toString("latin1").split("\r")`, hunting for an `MSH` anywhere in the payload), and
|
|
95
|
+
the two disagreed on real inputs: on a truncated MSH followed by a `PID` the correlator keyed on
|
|
96
|
+
the PID's MRN while `buildRawAck` echoed an empty MSA-2; on a payload with a **leading `CR`** —
|
|
97
|
+
which the MLLP decoder passes straight through — `buildRawAck` echoed MSH-10 correctly while the
|
|
98
|
+
correlator, requiring `MSH` at byte 0, gave up. Every such disagreement is an ACK the sender
|
|
99
|
+
cannot match.
|
|
100
|
+
- They now agree at the **tolerant** fixed point, not a lossy one: `readMshSegment` **locates** the
|
|
101
|
+
`MSH` (the first `CR`/`LF`-delimited segment starting with `MSH`) instead of demanding it at byte
|
|
102
|
+
0, so a leading `CR`/`LF` or an `FHS`/`BHS` batch header (§2.10.3) cannot hide a control ID that
|
|
103
|
+
is plainly present — and *then* bounds the field scan at that segment's terminator. Both rules
|
|
104
|
+
are needed and neither may be traded for the other. An interim version of this fix forced
|
|
105
|
+
agreement by requiring `MSH` at byte 0 everywhere, which "resolved" the leading-`CR`
|
|
106
|
+
disagreement by degrading the side that was **right**: `buildRawAck` began emitting a positive
|
|
107
|
+
`AA` with an empty MSA-2, **silently**, for a message whose MSH-10 was there to read — a
|
|
108
|
+
tolerance regression that manufactured the very duplicate-message failure this item exists to
|
|
109
|
+
close. A lenient reader may never drop data that is present (Postel's Law). `buildMllpAck` strips **leading
|
|
110
|
+
segment terminators only** before parsing, for the same reason and no further: `parseHL7` requires
|
|
111
|
+
`MSH` to be the first segment, and a leading `CR`/`LF` is pure terminator noise carrying no data,
|
|
112
|
+
so dropping it can hide nothing.
|
|
113
|
+
- **An HL7 batch (§2.10.3) is still refused, loudly.** `buildMllpAck` does not implement batch ACK,
|
|
114
|
+
so an `FHS`/`BHS` envelope falls through to `parseHL7`'s `NO_MSH_SEGMENT` and out into the
|
|
115
|
+
warned, non-positive `AE` fallback — exactly as before this item. An interim version of the fix
|
|
116
|
+
above re-based the payload on the *located* `MSH`, which skipped the batch envelope: the builder
|
|
117
|
+
then parsed only message 1, silently discarded every later `MSH` and the `BTS`/`FTS`, and returned
|
|
118
|
+
a positive **`AA` correlated to message 1 with zero warnings** for a batch whose messages 2..N it
|
|
119
|
+
had never read — telling the sender the whole batch was accepted. A positive ACK for a message
|
|
120
|
+
nobody looked at is what the commit contract exists to make structurally impossible. Batch ACK is
|
|
121
|
+
its own feature; it is not something to arrive at by accident via a byte-offset helper.
|
|
122
|
+
- **`buildRawAck` assumed `|` was the field separator instead of reading MSH-1
|
|
123
|
+
(MLLP-ACK-UTF8, sibling).** MSH-1 *is* the field separator (HL7 v2.5.1 §2.5.4) — the byte at
|
|
124
|
+
offset 3 of the MSH segment defines it — and the client-side scanners had always read it
|
|
125
|
+
dynamically. `buildRawAck` split on a hardcoded `|`, so a `!`-delimited message yielded one field
|
|
126
|
+
and **every** echoed field came back empty: the ACK went out as `MSA|AA|` with **no correlation id
|
|
127
|
+
at all**, unmatchable by construction. It now reads MSH-1 and echoes the inbound's own MSH-1/MSH-2,
|
|
128
|
+
which also keeps the echoed field *content* and the delimiters that define it together (re-emitting
|
|
129
|
+
`ID#X` under `^~\&` silently turns two components into one). Segment splitting now tolerates `LF`
|
|
130
|
+
and `CRLF` as well as `CR`, matching the scanners: an `LF`-terminated inbound previously left the
|
|
131
|
+
whole message as one "MSH" segment and emitted the ACK's MSH-12 as `2.5.1\nPID`, embedding a raw
|
|
132
|
+
`LF` and a stray segment id in the ACK. A framing byte (`VT`/`FS`) or segment terminator declared
|
|
133
|
+
as MSH-1 is refused and falls back to a minimal ACK, so the ACK can always be framed.
|
|
134
|
+
|
|
135
|
+
- **The client's ACK correlator masked the high bit out of the correlation key
|
|
136
|
+
(MLLP-CORRELATOR-ASCII).** `extractMshControlId` / `extractMsaControlId` decoded MSH-10 / MSA-2
|
|
137
|
+
with `ascii` (`byte & 0x7f`) — the same class of bug the Phase 10 entry below fixed in
|
|
138
|
+
`buildRawAck`, left behind on the client side since Phase 5, so the server's MSH-10 → MSA-2 echo
|
|
139
|
+
and the client's read-back did not agree on what a control ID *is*. The extracted string **is**
|
|
140
|
+
the correlator's key (live store, graveyard, ACK lookup), so a lossy decode is a lossy key: the
|
|
141
|
+
two legal, distinct control IDs `MSGÉ1` and `MSGI1` (`0xC9 & 0x7F === 0x49`) collapsed onto one
|
|
142
|
+
key, the second `enqueue()` overwrote the first in the `Map`, and the first send could never be
|
|
143
|
+
settled by its own ACK. The masked ID was also what reached `MLLP_ACK_UNMATCHED_CONTROL_ID` /
|
|
144
|
+
`MLLP_ACK_AFTER_TIMEOUT` observers and `MllpTimeoutError.messageControlId` — an ID that was never
|
|
145
|
+
on the wire, misdirecting the operator tracing a lost message. Reachable when MSH-18 declares a
|
|
146
|
+
non-ASCII charset (e.g. `8859/1`). Fixed: both extractors decode `latin1` (1:1 byte↔code-unit, so
|
|
147
|
+
distinct bytes stay distinct keys and no VT/FS can be synthesized). Six tests added under
|
|
148
|
+
`test/client/correlator-controlid.test.ts`, each failing under the old decode, one of them a
|
|
149
|
+
cross-path round-trip pinning `buildRawAck`'s echo and the client extractors to the same key.
|
|
150
|
+
Pure-ASCII control IDs are unaffected. **Scope at the time:** the two paths agreed byte-for-byte
|
|
151
|
+
only for the `|`-delimited messages `buildRawAck` supported — it still hardcoded `|` where the
|
|
152
|
+
extractors read the separator from MSH-1, and the `ack-from-hl7` subpath still round-tripped
|
|
153
|
+
control IDs through `utf8`. Both of those were left pre-existing then, and are **closed by
|
|
154
|
+
MLLP-ACK-UTF8** (above); the scanners are now a single shared implementation.
|
|
155
|
+
|
|
156
|
+
- **A peer could crash the server with one high-bit byte, and corrupt the ACK control ID
|
|
157
|
+
(Phase 10).** `buildRawAck` decoded the inbound message with `ascii`, which masks the high bit
|
|
158
|
+
(`byte & 0x7f`). Two consequences, both serious. **Spec:** MSA-2 must echo the inbound MSH-10
|
|
159
|
+
**verbatim** (HL7 v2.5.1 §2.9.2.2), but a control-ID byte `0x8B` silently became `0x0B` — a
|
|
160
|
+
*different* id — breaking the sender's own ACK correlation for any non-ASCII charset. **Safety:**
|
|
161
|
+
`0x8B → 0x0B` is a **VT** and `0x9C → 0x1C` is an **FS**, so `ascii` *synthesized framing
|
|
162
|
+
delimiters* from ordinary payload bytes; a peer sending one high-bit byte in an echoed MSH field
|
|
163
|
+
made the ACK payload contain a real VT/FS, which `encodeFrame` (strict) rejected — and that throw
|
|
164
|
+
escaped the `void`-ed `_sendCommitAck`, **crashing the whole server on peer-controlled input with
|
|
165
|
+
no consumer bug at all**, and suppressing the fail-safe ACK. Fixed: `buildRawAck` uses `latin1`
|
|
166
|
+
(byte-exact; a delivered payload cannot itself contain VT/FS, and `latin1` cannot synthesize
|
|
167
|
+
one), and `_dispatchAck` is now **total** — a frame failure (still reachable via a caller's
|
|
168
|
+
`autoAck: fn`) surfaces as a connection `'error'` and the message goes un-ACKed (fail-safe: the
|
|
169
|
+
sender resends), never a process kill. New suite `test/server/ack-serialization-safety.test.ts`.
|
|
170
|
+
- **Anything throwing on the receive path crashed the whole process — four routes, all closed
|
|
171
|
+
(Phase 10).** `FrameReader.push()` runs synchronously inside the transport's data callback, which
|
|
172
|
+
on a real socket **is** the `'data'` listener, so any throw there is an **uncaught exception** that
|
|
173
|
+
kills the process — every other connection and every in-flight durable commit with it. The
|
|
174
|
+
conformance gate refuted the fix three times, each round surfacing a route the previous fix had
|
|
175
|
+
missed — **four** in total:
|
|
176
|
+
1. **The decoder's own throw** — `Connection` fed `push(chunk)` with no `try`/`catch`. Reachable on
|
|
177
|
+
a **default server from a single byte**: `SERVER_DEFAULT_FRAMING` leaves `allowMissingLeadingVt`
|
|
178
|
+
off, so any non-whitespace byte where a `VT` was expected threw `MLLP_MISSING_LEADING_VT`
|
|
179
|
+
(`MLLP_FRAME_TOO_LARGE` reached the same path). One stray keepalive character from a real
|
|
180
|
+
interface engine was enough.
|
|
181
|
+
2. **`emit('error')` with no listener** — Node raises `ERR_UNHANDLED_ERROR`, and that throw happened
|
|
182
|
+
*inside the catch block added for (1)*, escaping by the identical route. `MllpServer`/
|
|
183
|
+
`MllpClient` each attach an `'error'` listener, which masked it; `Connection` is a public export
|
|
184
|
+
and need not.
|
|
185
|
+
3. **A throwing `'message'`/`'warning'` subscriber** — `onFrame` dispatches synchronously inside
|
|
186
|
+
`push()`, so an ordinary consumer bug (a metrics tap, a logger) unwound through the socket
|
|
187
|
+
handler too.
|
|
188
|
+
4. **The five lifecycle emits** — `destroy()` → `_transition()` → `emit('stateChange'|'close'|…)`
|
|
189
|
+
runs *inside* the catch block added for (1), and a throw raised inside a `catch` is **not**
|
|
190
|
+
caught by that block. A throwing `'close'` subscriber plus one stray byte still killed the
|
|
191
|
+
process, four frames up.
|
|
192
|
+
|
|
193
|
+
Enumerating routes one at a time is what produced a fourth, so the rule is now **structural: no
|
|
194
|
+
`emit()` in `Connection` may reach a transport callback.** All eight events dispatch through
|
|
195
|
+
containment, pinned by a test that attaches a throwing subscriber to every one of them at once.
|
|
196
|
+
|
|
197
|
+
Now: a fatal framing error surfaces as a frozen `'error'` event (`phase: 'receive'`,
|
|
198
|
+
`connectionCause: 'framing-fatal'`, the `MllpFramingError` preserved as `cause` so the stable
|
|
199
|
+
`code`/`byteOffset` survive) and **only that connection** is destroyed — a server drops the one bad
|
|
200
|
+
peer and keeps serving. Every `'error'` emit is guarded by `listenerCount` and wrapped. Subscriber
|
|
201
|
+
throws are contained per-subscriber at the dispatch site — what WARN-06 always promised but only
|
|
202
|
+
half-implemented (the `onWarning` *option* was guarded; the event broadcast was not). A fatal
|
|
203
|
+
framing error is also reported **exactly once** now — `destroy(err)` forwards the reason to
|
|
204
|
+
`transport.destroy(err)`, which made a real socket echo it back through `_onTransportError` and
|
|
205
|
+
emit a second, causeless `'error'`, double-counting on an alerting dashboard. The
|
|
206
|
+
connection is destroyed rather than resynchronized deliberately: after a throw the reader's position
|
|
207
|
+
in the byte stream is untrustworthy, and guessing where the next frame begins is how a clinical
|
|
208
|
+
message gets silently mis-split. The existing suites missed all of this because the in-memory
|
|
209
|
+
transport wraps delivery in `try`/`finally`, re-routing the throw to the *writer*; only a real
|
|
210
|
+
socket reproduces it. New suites: `test/server/framing-error-containment.test.ts` (real loopback
|
|
211
|
+
sockets) and `test/connection/receive-containment.test.ts` (drives the data callback directly) —
|
|
212
|
+
both verified to fail without the fixes.
|
|
213
|
+
- **A fatal framing error triggered an unbounded reconnect storm (Phase 10).**
|
|
214
|
+
`isTransientConnectionError` switches on `err.code` and fell through to `default: return true`, so a
|
|
215
|
+
`MllpFramingError` was classified **transient**. `createStarterClient` (where `autoReconnect`
|
|
216
|
+
defaults **on**) therefore retried forever against a peer that was not speaking MLLP — an HTTP probe,
|
|
217
|
+
a health check, a wrong-port misconfiguration — with the backoff hammering an interface engine that
|
|
218
|
+
was already misconfigured. `MLLP_*` codes are now **permanent**, alongside the TLS classes and for
|
|
219
|
+
the same reason: every reconnect meets the same bytes.
|
|
220
|
+
- **A throwing `'message'` observer suppressed the ACK (Phase 10).** `MllpServer` emits `'message'` to
|
|
221
|
+
observers *before* ACK dispatch (D-03), so an observer that threw aborted the handler before the ACK
|
|
222
|
+
was sent — one broken logger silently turned every message into a no-ACK, and every sender resent
|
|
223
|
+
forever with nothing to diagnose it by. The emit is now contained: the throw surfaces on `'error'`
|
|
224
|
+
and the commit contract proceeds untouched. The ACK decision belongs to `ServerOptions.onMessage`
|
|
225
|
+
(the durable-commit step), not to a metrics tap.
|
|
226
|
+
- **Release pipeline could not have released (Phase 10).** The shared `cosyte/.github` release
|
|
227
|
+
workflow drives Changesets with `version: pnpm run version`, but no `version` script existed —
|
|
228
|
+
it failed with `ERR_PNPM_NO_SCRIPT`, so the "Version Packages" PR could never be opened. Added
|
|
229
|
+
`version` (`changeset version` → `scripts/sync-version.mjs` → `prettier --write`).
|
|
230
|
+
- **The `VERSION` export would have lied about the release (Phase 10).** `VERSION` was hardcoded
|
|
231
|
+
`"0.0.0"` in `src/index.ts`, while `changeset version` bumps only `package.json` — the published
|
|
232
|
+
`0.0.1` would have exported `"0.0.0"`. `scripts/sync-version.mjs` now rewrites the constant from
|
|
233
|
+
`package.json` inside the `version` script, and `test/sanity.test.ts` compares the export against
|
|
234
|
+
`package.json` rather than asserting a hardcoded literal against a hardcoded literal (the old
|
|
235
|
+
test would have stayed green through precisely this drift).
|
|
236
|
+
- **`VERSION` had a literal type in the published `.d.ts` (Phase 10).** It declared
|
|
237
|
+
`const VERSION = "0.0.0"`, leaking the current release into consumers' types and turning an
|
|
238
|
+
equality check against any other version into a compile error. Now `VERSION: string`.
|
|
239
|
+
- **Docs accuracy (Phase 10).** Found by the conformance gate, which refuted the first cut of the
|
|
240
|
+
new guide:
|
|
241
|
+
- `docs-content/intro.md` described the decoder as liberal outright. It is **strict by default** —
|
|
242
|
+
tolerance is opt-in per flag, and it is `MllpServer` that ships tolerant defaults (`allowFsOnly`,
|
|
243
|
+
`allowLfAfterFs`, `allowLeadingWhitespace`; `allowMissingLeadingVt` stays off even there).
|
|
244
|
+
- `MLLP_TRAILING_BYTES` is **not** benign junk between frames. It fires on a `VT` appearing
|
|
245
|
+
*mid-payload* — which **discards the accumulated partial payload**, i.e. a **truncated**
|
|
246
|
+
message — and on a stray byte after `FS` under `allowFsOnly`. Now documented as something to
|
|
247
|
+
alert on rather than ignore.
|
|
248
|
+
- **`close()` does not drain in-flight messages** — it *rejects* them with
|
|
249
|
+
`MllpConnectionError({ phase: 'close' })`. No drain hook is wired to the `DRAINING` state, so
|
|
250
|
+
`drainTimeoutMs` does not currently bound an in-flight ACK wait on the client. A message in
|
|
251
|
+
flight at shutdown is an **unknown**, not a failure: the receiver may have committed it. Now
|
|
252
|
+
stated honestly in the reliability guide and the limitations page, with the "await your sends,
|
|
253
|
+
then close" pattern.
|
|
254
|
+
- The absolute PHI claim ("never echoes message content") is now precise: diagnostics never echo a
|
|
255
|
+
*run* of content, but the single-byte `snippet` on `MLLP_MISSING_LEADING_VT` is by definition the
|
|
256
|
+
first byte of unframed content.
|
|
257
|
+
|
|
258
|
+
- **`buildRawAck` could emit an ACK whose MSH-2 collided with its MSH-1 (MLLP-ACK-UTF8).** When an
|
|
259
|
+
inbound declared no usable MSH-2, the builder fell back to the HL7 default encoding characters
|
|
260
|
+
`^~\&` **without checking them against MSH-1**. For an inbound declaring MSH-1 = `^` (or `~`, `\`,
|
|
261
|
+
`&`), the fallback's first character *is* the field separator, so the emitted ACK read back with an
|
|
262
|
+
**empty MSH-2** and every later MSH field shifted by one (§2.5.4, §2.16 — the delimiters must be
|
|
263
|
+
distinct). Fixed: `buildRawAck` substitutes only the one colliding **encoding character** and keeps
|
|
264
|
+
the inbound's **field separator** unchanged.
|
|
265
|
+
- Keeping the field separator is the load-bearing part. The field separator is the only byte that
|
|
266
|
+
can truncate MSA-2, and MSH-10 provably cannot contain it (MSH-10 is a product of splitting the
|
|
267
|
+
inbound MSH *on* it). An interim fix instead switched the ACK's field separator to `|` — and
|
|
268
|
+
since a `|` inside an `^`-delimited message's MSH-10 is *ordinary data* (§2.5.4), an MSH-10 of
|
|
269
|
+
`ID|X` went out as `MSA|AA|ID|X`, which a receiver reads back as **`ID`**: silently **truncated**.
|
|
270
|
+
Truncated is worse than empty — `ID` is *plausible*, so it can match a **different** in-flight
|
|
271
|
+
send and falsely settle it, losing one clinical message (its `send()` resolves though it was
|
|
272
|
+
never acknowledged) and duplicating another (the one actually acknowledged stays in flight and
|
|
273
|
+
resends). Substituting the encoding character avoids all of this: the ACK stays under the
|
|
274
|
+
inbound's own delimiters, so the echo round-trips byte-for-byte whatever the control ID contains.
|
|
275
|
+
- It deliberately does **not** fall through to the minimal ACK, which would drop the MSA-2 echo: an
|
|
276
|
+
ACK that is well-formed but uncorrelatable is worse than one that correlates with an imperfect
|
|
277
|
+
header. The control-ID echo is the thing being protected.
|
|
278
|
+
|
|
279
|
+
### Added
|
|
280
|
+
|
|
281
|
+
- **`MLLP_ACK_CONTROL_ID_NOT_VERBATIM` (MLLP-ACK-UTF8).** A new stable warning code —
|
|
282
|
+
`ack-from-hl7`-scoped, emitted in `MllpAck.warnings`, not through the framing registry (13 codes
|
|
283
|
+
total now). `buildMllpAck` **verifies** every ACK it builds against the very byte-level scanners the
|
|
284
|
+
`@cosyte/mllp` client uses to correlate, and warns when MSA-2 is not byte-identical to the inbound
|
|
285
|
+
MSH-10 (HL7 v2.5.1 §2.9.2.2). The warning reports the two byte **lengths** and withholds the
|
|
286
|
+
field values — MSH-10 is inbound payload content and a warning goes to a log. The ACK is still
|
|
287
|
+
emitted — a
|
|
288
|
+
mismatched ACK beats silence — but the mismatch can no longer pass unremarked, because a
|
|
289
|
+
non-verbatim MSA-2 *is* an ACK the sender cannot match. It fires for an `encoding` override that
|
|
290
|
+
cannot round-trip the inbound bytes, and for an inbound declaring non-default delimiters or a
|
|
291
|
+
whitespace-padded control ID — cases `@cosyte/hl7`'s builder structurally cannot represent (it
|
|
292
|
+
always emits `|^~\&`, and trims field whitespace). `buildRawAck` is parser-free and has neither
|
|
293
|
+
limit, so it remains the answer for a non-default-delimiter peer.
|
|
294
|
+
|
|
295
|
+
**The check is a `Buffer` guarantee, and the docs now say so.** On a `string`/`Hl7Message` inbound
|
|
296
|
+
the wire bytes are decoded before `buildMllpAck` ever sees them, so it re-encodes the caller's text
|
|
297
|
+
with the same codec it decoded it with: the codec cancels on both sides and a codec-induced
|
|
298
|
+
mismatch is **structurally invisible**. `buildAckAA(payload.toString("latin1"))` on a high-bit
|
|
299
|
+
control ID emits `0xC2 0x8B` — a different control ID — and warns about nothing. That double-encode
|
|
300
|
+
is pre-existing (byte-identical on the previous release line) and is tracked separately; the guard
|
|
301
|
+
cannot be grown to catch it, because by then the bytes are gone. What was *new* and is now fixed is
|
|
302
|
+
the **claim** that it could not happen: `docs-content/acks.md`, `docs-content/limitations.md`, and
|
|
303
|
+
the `BuildMllpAckOptions.encoding` JSDoc each asserted the echo was "never silently wrong". They now
|
|
304
|
+
scope the guarantee to `Buffer` inbound and name `Buffer` as the byte-safe path, and a test pins the
|
|
305
|
+
limitation so the claim cannot silently re-broaden.
|
|
306
|
+
- **`ConnectionErrorCause` gains `'framing-fatal'` (Phase 10).** Public union. Attached to the
|
|
307
|
+
`'error'` event when the decoder throws; classified **permanent** by `isTransientConnectionError`,
|
|
308
|
+
so a client never auto-reconnects into a peer that is not speaking MLLP.
|
|
309
|
+
- **Release readiness for `0.0.1` (Phase 10).**
|
|
310
|
+
- **Publish pipeline proven without burning a version.** New `publish:dry` script
|
|
311
|
+
(`pnpm publish --dry-run --no-git-checks`). Verified end to end: the `prepublishOnly` chain
|
|
312
|
+
(clean → typecheck → lint → test → build → `attw`) is green; `changeset version` consumes the
|
|
313
|
+
pending changesets to exactly **`0.0.1`**; the dry-run packs 24 files (`dist/` + README +
|
|
314
|
+
LICENSE + CHANGELOG) with public access and no `src/`, `test/`, or `vendor/` leakage;
|
|
315
|
+
`pack:docs` produces both docs artifacts. **Nothing was published** — the first real publish
|
|
316
|
+
is a human gate.
|
|
317
|
+
- **Full `docs-content/` transport guide.** New pages: **Framing & tolerance** (wire format, the
|
|
318
|
+
opt-in tolerance flags with a `FrameReader`-vs-`MllpServer` default table, the stable
|
|
319
|
+
warning-code registry, bounded accumulators, the PHI contract on diagnostics); **ACKs & the
|
|
320
|
+
commit contract** (fail-safe ACK semantics, the three `autoAck` modes, the transport-accept
|
|
321
|
+
caveat, FIFO-vs-control-id correlation, `ack-from-hl7`); **Connection, reconnect &
|
|
322
|
+
backpressure** (the 6-state machine, jittered exponential backoff, the transient-vs-permanent
|
|
323
|
+
classifier, keepalive vs dead-peer timeout, high-water marks, graceful drain); and **Known
|
|
324
|
+
limitations & non-goals** (at-least-once at best, no queue/replay, no MLLP R2, no Epic/Cerner
|
|
325
|
+
differential verification, no PKI, not byte-transparent, pre-stable API). Sidebar updated; all
|
|
326
|
+
examples synthetic.
|
|
327
|
+
- **README** now documents the commit contract and the non-goals, not just the feature list.
|
|
328
|
+
|
|
329
|
+
- **Real-world interop, differential conformance & PHI/observability audit (Phase 9).**
|
|
330
|
+
- **PHI hardening (framing).** `MllpFramingError.snippet` no longer carries a run of payload content
|
|
331
|
+
bytes on **either** framing path. (1) The decoder's `MLLP_FRAME_TOO_LARGE` used to copy the last 32
|
|
332
|
+
accumulated payload bytes into `snippet` (the too-large frame is a full HL7 message) — now empty (the
|
|
333
|
+
anomaly is the frame's *size*, not a byte). (2) The encoder's `MLLP_PAYLOAD_CONTAINS_VT`/`_FS` (strict,
|
|
334
|
+
reachable from `client.send()`) copied up to 64 payload bytes around the offending delimiter — now
|
|
335
|
+
just the single offending delimiter byte (a VT/FS control byte the `code` already names). Every
|
|
336
|
+
framing throw now carries at most the single framing-boundary byte that broke the structure, never a
|
|
337
|
+
payload run; the `snippet` PHI contract is documented on the field. No public-API change.
|
|
338
|
+
- **Differential harness** (`test/differential/`) — byte-parity with the Google Cloud Healthcare MLLP
|
|
339
|
+
adapter and Mirth/NextGen Connect (both R1). Tier 1 (always on) asserts decode + `encodeFrame`
|
|
340
|
+
byte-parity against canonical R1 golden frames and ACK correlation; Tier 2 (`MLLP_DIFF_ADAPTER`
|
|
341
|
+
opt-in) checks a live adapter and skips cleanly when unset, so `verify` stays green.
|
|
342
|
+
- **Quirk corpus** (`test/conformance/`) — a realistic multi-segment HL7 message driven through each
|
|
343
|
+
§3 real-world deviation, asserting the exact warning code/typed error and byte-identical payload
|
|
344
|
+
recovery; the lenient decoder never throws except the sanctioned `MLLP_FRAME_TOO_LARGE`.
|
|
345
|
+
- **PHI-safety property suite** — generative proof (mutation-checked) that no framing diagnostic ever
|
|
346
|
+
echoes payload content, including the oversized path.
|
|
347
|
+
- **Test-infra:** the pre-existing `test/server/*` suites now use the shared
|
|
348
|
+
`test/helpers/tracked-servers.ts` (`must()` + `makeServerTracker()`) instead of copy-pasted helpers.
|
|
349
|
+
- **Scope note:** the remaining Phase 9 roadmap acceptance items — (c) keepalive / half-open
|
|
350
|
+
detection and (d) fuzz chunk-boundary adversaries — were already delivered in earlier phases
|
|
351
|
+
(`socket.setKeepAlive` in `src/server/server.ts`; the byte-at-a-time `randomChunks` /
|
|
352
|
+
`split(1)` generators in `test/property/fuzz.property.test.ts`), so Phase 9 legitimately
|
|
353
|
+
narrows to the PHI snippet audit + differential harness + quirk corpus.
|
|
354
|
+
- **TLS / MLLPS hardening (Phase 8).** `TlsTransport` (wraps `tls.TLSSocket`, maps `onConnect` to
|
|
355
|
+
`'secureConnect'`) joins `NetTransport` as a first-class `Transport`. Client: `ClientOptions.tls?:
|
|
356
|
+
TlsOptions | true` — verification **on by default**; the only opt-out is the loud
|
|
357
|
+
`allowUnverified` flag, which emits a frozen `'securityWarning'` (`MLLP_TLS_VERIFY_DISABLED`) +
|
|
358
|
+
`process.emitWarning` on every `secureConnect` (initial connect and every reconnect). Server:
|
|
359
|
+
`ServerOptions.tls?: ServerTlsOptions` with `clientAuth: 'NONE' | 'WANT' | 'MUST'` (ATNA ITI-19
|
|
360
|
+
mutual node authentication) — `'WANT'`/`'MUST'` surface a minimal, content-free `peerCertificate`
|
|
361
|
+
(`{ subjectCN, issuerCN, validTo, authorized }`) on the `'connection'` event; `authorized`
|
|
362
|
+
reports whether the chain was verified against `ca` (under `'WANT'` a certificate can be present
|
|
363
|
+
yet unverified — never authorize on `subjectCN` alone); `'MUST'` additionally rejects
|
|
364
|
+
unauthorized/missing client certificates. Failed handshakes (incl. rejected mTLS client certs)
|
|
365
|
+
never crash the server: a frozen `'tlsClientError'` event (`{ remoteAddress, remotePort, message,
|
|
366
|
+
code, timestamp }`) is emitted and the server keeps accepting other connections. Both `minVersion`
|
|
367
|
+
default to `'TLSv1.2'` — the IHE ATNA ITI-19 "TLS 1.2 Floor" (BCP195) floor (ITI TF-2 §3.19.6.2.3);
|
|
368
|
+
`'TLSv1.0'/'TLSv1.1'` are not expressible through this API. No bundled cipher list — `ciphers`
|
|
369
|
+
passes through to Node's OpenSSL defaults, which already include both ATNA-mandated ECDHE suites.
|
|
370
|
+
New typed failure modes on `MllpConnectionError.connectionCause`: `'tls-verify'` (certificate
|
|
371
|
+
verification failure) and `'tls-handshake'` (TLS-**protocol**-shaped pre-`secureConnect` failures
|
|
372
|
+
only — `ERR_SSL_*`, `EPROTO`, OpenSSL alert-bearing errors; pure TCP failures like `ECONNREFUSED`
|
|
373
|
+
carry no `connectionCause`, same as plaintext). Both classes are **permanent** for the reconnect
|
|
374
|
+
classifier — never auto-reconnect-looped into a misconfigured or MITM'd endpoint — while plain
|
|
375
|
+
network blips stay transient. TLS 1.3 honesty note (RFC 8446 §4.4.2): `connect()` resolving does
|
|
376
|
+
NOT guarantee a `clientAuth: 'MUST'` server accepted the client certificate — a rejection
|
|
377
|
+
surfaces moments later as a typed post-connect error classified permanent; ACK correlation
|
|
378
|
+
remains the delivery guarantee. New exported helpers `isTlsVerificationErrorCode(code)` and
|
|
379
|
+
`isTlsProtocolError(err)`. New stats fields: `ClientStats.tls`, `ServerStats.tls`,
|
|
380
|
+
`ServerStats.tlsClientErrorsTotal`. New root exports: `TlsTransport`, `TlsOptions`,
|
|
381
|
+
`ServerTlsOptions`, `ClientAuth`, `SecurityWarning`, `MLLP_TLS_VERIFY_DISABLED`,
|
|
382
|
+
`MLLP_BIND_ALL_INTERFACES`, `isTlsVerificationErrorCode`, `isTlsProtocolError`. See
|
|
383
|
+
`docs-content/tls.md` for the full guide (mTLS table, TLS-1.3 client-cert-rejection note,
|
|
384
|
+
known limitations).
|
|
385
|
+
- **`@cosyte/mllp/ack-from-hl7` — real helpers (Phase 7); stub removed.** A thin transport
|
|
386
|
+
adapter over `@cosyte/hl7`'s `buildAck` (hl7 owns ACK content + the HL7 control tables;
|
|
387
|
+
this package frames and correlates — O-1 boundary). New surface: `buildMllpAck(inbound,
|
|
388
|
+
{ code, error?, encoding?, allowDelimiterBytesInPayload? })` returning a frozen `MllpAck`
|
|
389
|
+
(`frame` ready-to-write MLLP bytes, unframed `payload`, the built `ack` message,
|
|
390
|
+
`requestedCode` vs emitted `code`, verbatim `correlationId`, detected `mode`, content-free
|
|
391
|
+
`warnings`); the six Table-0008 conveniences `buildAckAA/AE/AR/CA/CE/CR`; `detectMode`
|
|
392
|
+
(original-vs-enhanced from MSH-15/16); lazy peer loading with a typed
|
|
393
|
+
`MllpPeerMissingError` (`MLLP_PEER_MISSING`) when `@cosyte/hl7` is absent; and the
|
|
394
|
+
`loadHl7Peer` seam. Fail-safe by construction: a fatally-unparseable inbound never yields
|
|
395
|
+
a positive ACK (`AA`→`AE`, `CA`→`CE` via the peer's `downgradePositiveAck` — no divergent
|
|
396
|
+
copy of the pair), MSA-2 stays empty, and the result carries the new stable warning code
|
|
397
|
+
**`MLLP_ACK_INBOUND_UNPARSEABLE`** (public API; 12 codes total). A parseable inbound with
|
|
398
|
+
no MSH-10 rides the peer's own downgrade + `ACK_NO_CORRELATION_ID`. MSA-2 echoes the
|
|
399
|
+
inbound MSH-10 whole — delimiter-bearing vendor-quirk ids (`ID^X`) byte-exact, matching
|
|
400
|
+
this package's own raw-bytes client correlator (escape-bearing ids canonicalize; see the
|
|
401
|
+
docs' known limitations).
|
|
402
|
+
- **Dev/test consumption of the unpublished `@cosyte/hl7` peer** via a vendored packed
|
|
403
|
+
tarball (`vendor/cosyte-hl7-0.0.0.tgz`, devDependency) so the accuracy suite runs against
|
|
404
|
+
the real peer in CI — an interim mechanism until the cross-repo consumption decision
|
|
405
|
+
lands; the runtime peer stays optional and is never bundled (`external` in tsup).
|
|
406
|
+
|
|
407
|
+
### Security
|
|
408
|
+
|
|
409
|
+
- **Dev-dependency advisory remediation (no runtime impact — `@cosyte/mllp`
|
|
410
|
+
ships zero runtime dependencies, so the published artifact is unchanged).**
|
|
411
|
+
Added scoped `pnpm.overrides` pinning two transitive **dev/build-time**
|
|
412
|
+
packages to their patched releases: `esbuild` (`>=0.27.3 <0.28.1` →
|
|
413
|
+
`0.28.1`; GHSA dev-server path-traversal — not reachable here: the library
|
|
414
|
+
builds via `tsup`/`vitest` and never runs `esbuild serve`) and the
|
|
415
|
+
`@changesets/parse` copy of `js-yaml` (`>=4.0.0 <4.2.0` → `4.2.0`;
|
|
416
|
+
GHSA-h67p-54hq-rp68 merge-key DoS). The `js-yaml@3.14.2` pulled by
|
|
417
|
+
`read-yaml-file@1.1.0` (via `@manypkg/get-packages` → `@changesets/cli`) is
|
|
418
|
+
**intentionally left**: it calls `yaml.safeLoad`, removed/throwing in
|
|
419
|
+
js-yaml 4, so it cannot be force-upgraded without breaking the release
|
|
420
|
+
tooling, and it only parses trusted local repo YAML at release time. This is
|
|
421
|
+
the shared canonical override block, enforced suite-wide by the
|
|
422
|
+
`@cosyte/config` drift check.
|
|
423
|
+
|
|
424
|
+
### Added
|
|
425
|
+
|
|
426
|
+
- **MLLP client + server** — production-grade client and server with framing
|
|
427
|
+
(`VT + payload + FS + CR`), ACK correlation, auto-reconnect with backoff, and backpressure.
|
|
428
|
+
Buffer-first API on every public surface.
|
|
429
|
+
- **Explicit 6-state connection machine** — `CONNECTING | CONNECTED | DRAINING | RECONNECTING |
|
|
430
|
+
DISCONNECTED | CLOSED`, with `stateChange` events carrying `{ from, to, reason }`.
|
|
431
|
+
- **Framing** — `FrameReader` with a bounded 16 MB default accumulator (`MLLP_FRAME_TOO_LARGE` on
|
|
432
|
+
overflow); strict encoder, lenient decoder (Postel's Law).
|
|
433
|
+
- **11 stable warning codes** with byte-offset context (`MLLP_MISSING_LEADING_VT`,
|
|
434
|
+
`MLLP_FS_WITHOUT_CR`, `MLLP_FRAME_TOO_LARGE`, `MLLP_ACK_UNMATCHED_CONTROL_ID`, …).
|
|
435
|
+
- **TLS** support; `AbortSignal` on every awaitable and `Symbol.asyncDispose` on every closeable.
|
|
436
|
+
- **In-memory transport** (`@cosyte/mllp/testing`) — a deterministic, socket-free test double.
|
|
437
|
+
- **`ack-from-hl7` subpath** — placeholder for building ACKs from parsed messages via the optional
|
|
438
|
+
`@cosyte/hl7` peer (helpers not yet implemented; Phase 6).
|
|
439
|
+
- **Property + fuzz test layer** for the framing transport, built on the shared
|
|
440
|
+
`@cosyte/test-utils` conformance kit and `fast-check` (both dev-only). Covers: codec round-trip
|
|
441
|
+
byte fidelity (`encode → decode`) via `roundTripProperty`; lenient-decoder robustness
|
|
442
|
+
(malformed-but-recoverable frames recover into warnings, only `MLLP_FRAME_TOO_LARGE` throws) via
|
|
443
|
+
`lenientNeverThrowsProperty`; frozen-event-payload immutability via `immutabilityProperty`; a
|
|
444
|
+
warning-code surface snapshot tripwire via `sortedCodeSet`; and a transport-robustness **fuzz**
|
|
445
|
+
property feeding arbitrary random byte buffers and chunk-splits through `FrameReader` over the
|
|
446
|
+
in-memory transport. Test-only — no public-surface change.
|
|
447
|
+
|
|
448
|
+
- **Fail-safe ACK semantics & the commit contract (Phase 6, HL7 v2.5.1 §2.9.2).** A positive
|
|
449
|
+
acknowledgement (`AA`) can never precede a successful durable commit: with `autoAck: 'AA'` + an
|
|
450
|
+
`onMessage` handler the server **awaits the handler (the commit step) then ACKs** — `AA` on resolve,
|
|
451
|
+
a **negative** code on throw/reject (`AE` by default; `AR` via `MllpAckError`), never `AA` before
|
|
452
|
+
commit. `autoAck: 'AA'` without a handler is a documented **transport-accept** (received+framed, not
|
|
453
|
+
application-processed). New public surface: `buildRawAck` (parser-free byte-level ACK builder echoing
|
|
454
|
+
inbound `MSH-10` into `MSA-2`, never throwing on malformed input), the HL7 Table 0008 `AckCode` /
|
|
455
|
+
`NegativeAckCode` unions, `MllpAckError`, `resolveNackCode`, and a PHI-safe `'nack'` event
|
|
456
|
+
(`{ connectionId, ackCode }`) with its `NackEvent` type. No payload content or thrown error text ever
|
|
457
|
+
reaches the wire, logs, or events — only routing/control metadata and the static ack code.
|
|
458
|
+
- **Package metadata** — added `homepage` and `bugs` fields to `package.json` for npm completeness.
|
|
459
|
+
|
|
460
|
+
### Changed
|
|
461
|
+
|
|
462
|
+
- **Server bind-safety hardening (Phase 8; BREAKING pre-publish — free before first release).**
|
|
463
|
+
`MllpServer.listen()` / `createStarterServer` default bind host changed `'0.0.0.0'` →
|
|
464
|
+
`'127.0.0.1'`. Binding a wildcard host now requires `ServerOptions.allowWildcardBind: true` —
|
|
465
|
+
**enforced against the OS-normalized bound address**, not the requested spelling. Literal
|
|
466
|
+
wildcard spellings (`'0.0.0.0'`, `'::'`, `''`, `'::0'`, `'0:0:0:0:0:0:0:0'`,
|
|
467
|
+
`'::ffff:0.0.0.0'`) reject with a typed `MllpConnectionError` **before** binding;
|
|
468
|
+
resolver-only shorthands (`'0'`, `'0.0'`, `'0x0.0.0.0'`, …) are caught by a post-bind check
|
|
469
|
+
on `server.address()` — the just-bound server closes immediately and `listen()` rejects,
|
|
470
|
+
leaving no listening state and emitting no `'listening'` event. `listen()` is **single-flight**:
|
|
471
|
+
a call while the server is already listening, or while another `listen()` is in flight, rejects
|
|
472
|
+
with a typed `MllpConnectionError` instead of racing the first call's post-bind checks (a lost
|
|
473
|
+
race could otherwise record listening state for a bind that no longer exists); `close()` before
|
|
474
|
+
re-listening. When a wildcard host IS bound with the flag set, the server emits a one-time
|
|
475
|
+
frozen `'securityWarning'` (`MLLP_BIND_ALL_INTERFACES`) + `process.emitWarning`, keyed off the
|
|
476
|
+
bound address.
|
|
477
|
+
- **Renamed the package `@cosyte/hl7-mllp` → `@cosyte/mllp`.** Not yet published, so no deprecation
|
|
478
|
+
path is needed; all imports, the `/testing` and `/ack-from-hl7` subpaths, and the optional
|
|
479
|
+
`@cosyte/hl7` peer dependency are unchanged.
|
|
480
|
+
- **Adopted the shared `@cosyte/*` engineering standard (Phase E).** Build via `@cosyte/tsup-config`
|
|
481
|
+
(`cosyteTsup`), tests via `@cosyte/vitest-config` (`cosyteVitest`), lint via `@cosyte/eslint-config`
|
|
482
|
+
(ESLint 10 + `typescript-eslint`) at `--max-warnings=0`, Prettier 3.8. Exact-pinned dev tooling,
|
|
483
|
+
canonical scripts (incl. `clean`, `attw --pack .`), per-condition `.d.cts` types on every `exports`
|
|
484
|
+
subpath, and thin-caller `ci.yml` / `release.yml` over `cosyte/.github`. Target bumped ES2022 →
|
|
485
|
+
ES2023.
|
|
486
|
+
- **Re-enabled the coverage gate** at per-directory >= 90 on
|
|
487
|
+
`framing|client|connection|server|transport` (was disabled).
|
|
488
|
+
- **Restored the JSDoc `error` gate** (the local `warn` downgrade was removed) and reformatted the
|
|
489
|
+
source to the shared Prettier config (double quotes); no behavior change.
|
|
490
|
+
|
|
491
|
+
### Removed
|
|
492
|
+
|
|
493
|
+
- **`mitata` benchmark dependency and the `bench` script** — the script had no benchmark files.
|
|
494
|
+
|
|
495
|
+
### Deprecated
|
|
496
|
+
|
|
497
|
+
### Fixed
|
|
498
|
+
|
|
499
|
+
- **Bind errors no longer crash a server with no `'error'` listener (Phase 8 residuals, MLLP-8.1).**
|
|
500
|
+
The constructor-time `net.Server`/`tls.Server` `'error'` forwarder ran before `listen()`'s own
|
|
501
|
+
rejection handler and re-emitted unconditionally — on a server with no `'error'` listener, a plain
|
|
502
|
+
bind error (`EADDRINUSE`, `EACCES`, …) crashed the process (unlistened `EventEmitter` `'error'`
|
|
503
|
+
emissions throw) instead of rejecting the `listen()` promise. The forwarder is now guarded by
|
|
504
|
+
**server state**, and the error contract is documented: with a listener attached, always
|
|
505
|
+
forwarded; with none, a bind-window error rejects the `listen()` promise (the **primary** error
|
|
506
|
+
surface — caveat: an `'error'` listener that synchronously calls `close()` during the bind window
|
|
507
|
+
changes the rejection to the typed close-during-listen error), a stale error after `close()` is
|
|
508
|
+
dropped, and a runtime error **while serving** (e.g. accept-loop `EMFILE`) deliberately keeps
|
|
509
|
+
Node's fail-loud crash-on-unlistened-`'error'` convention — a silent accept outage is impossible.
|
|
510
|
+
A throwing `'listening'`/`'securityWarning'` subscriber can no longer strand the `listen()`
|
|
511
|
+
promise and wedge the single-flight guard: each emit is contained separately (a throw in one
|
|
512
|
+
subscriber cannot suppress a later emission — in particular the `MLLP_BIND_ALL_INTERFACES`
|
|
513
|
+
security warning always survives, with the operator-channel `process.emitWarning` fired first so
|
|
514
|
+
no event listener can ever suppress it), the throw is surfaced via the guarded `'error'` tap
|
|
515
|
+
(itself contained against a double throw), and `listen()` still resolves. Also consolidates `listen()`'s five hand-woven settle paths (abort,
|
|
516
|
+
close-during-listen, no-address reject, post-bind wildcard reject, bind error) into one idempotent
|
|
517
|
+
first-caller-wins settle helper — no path can leak a listener, strand the single-flight guard, or
|
|
518
|
+
re-settle a settled promise — and documents three subtleties: the post-bind wildcard reject window
|
|
519
|
+
is accept-safe (the check runs synchronously on `'listening'`, before any connection can be
|
|
520
|
+
delivered); `close({ signal })` with an **already-aborted** signal is a no-op `AbortError`
|
|
521
|
+
rejection that does **not** settle an in-flight `listen()` (which continues and settles on its own
|
|
522
|
+
bind outcome); and once the bind has succeeded, an abort of the listen signal fired from inside a
|
|
523
|
+
`'listening'`/`'securityWarning'` handler is deliberately **too late** — the bind wins and
|
|
524
|
+
`listen()` resolves (use `close()` to shut down), so aborted-mid-emit can never strand
|
|
525
|
+
`listening: true` on a closed socket.
|
|
526
|
+
|
|
527
|
+
### Security
|
|
528
|
+
|
|
529
|
+
[Unreleased]: https://github.com/cosyte/mllp/commits/main
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cosyte
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|