@cosyte/mllp 0.0.1 → 0.0.2
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 +269 -108
- package/README.md +23 -18
- package/TRADEMARKS.md +30 -0
- package/dist/ack-from-hl7/index.cjs +54 -14
- package/dist/ack-from-hl7/index.cjs.map +1 -1
- package/dist/ack-from-hl7/index.d.cts +113 -38
- package/dist/ack-from-hl7/index.d.ts +113 -38
- package/dist/ack-from-hl7/index.mjs +53 -14
- package/dist/ack-from-hl7/index.mjs.map +1 -1
- package/dist/{index-DjGQJtYE.d.cts → index-DAZ4BvJE.d.cts} +5 -5
- package/dist/{index-DjGQJtYE.d.ts → index-DAZ4BvJE.d.ts} +5 -5
- package/dist/index.cjs +90 -90
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +239 -239
- package/dist/index.d.ts +239 -239
- package/dist/index.mjs +90 -90
- package/dist/index.mjs.map +1 -1
- package/dist/testing/index.cjs +3 -3
- package/dist/testing/index.cjs.map +1 -1
- package/dist/testing/index.d.cts +8 -8
- package/dist/testing/index.d.ts +8 -8
- package/dist/testing/index.mjs +3 -3
- package/dist/testing/index.mjs.map +1 -1
- package/package.json +15 -7
package/CHANGELOG.md
CHANGED
|
@@ -12,60 +12,214 @@ this file is maintained by hand (Changesets handles the version bump and publish
|
|
|
12
12
|
The first pre-alpha release (`0.0.1`) will ship the v1 MLLP transport surface below. The package
|
|
13
13
|
begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` until first alpha).
|
|
14
14
|
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **Em-dash brand gate (`scripts/check-no-emdash.sh`, `pnpm check:no-emdash`, and
|
|
18
|
+
`.github/workflows/no-emdash.yml`).** Enforces the founder directive that bans `U+2014`
|
|
19
|
+
outright (`knowledgebase/06-brand/voice-and-tone.md`: "No em dashes. Ever."), which names
|
|
20
|
+
commit messages explicitly. It covers both halves of the rule: every tracked file, and the
|
|
21
|
+
PR title, body, and commit messages, the latter on the non-default `edited` trigger so
|
|
22
|
+
retitling a PR re-checks it. Its own CI workflow rather than a job in `ci.yml`, because
|
|
23
|
+
`edited` would otherwise re-run the whole Node 22 + 24 matrix on a typo fix.
|
|
24
|
+
**mllp was already clean, measured over all 152 tracked files byte by byte rather than over
|
|
25
|
+
markdown alone** (a markdown-only count is what wrongly cleared `dicom`, which held six live
|
|
26
|
+
em dashes in four non-markdown files), so this gate changed no content and exists purely to
|
|
27
|
+
stop a regression.
|
|
28
|
+
The script is **composed** from three sibling copies, not taken from one: `website`'s
|
|
29
|
+
NUL-exclusion shape as the base, `ncpdp`'s two route fixes (a tracked file named `-` was read
|
|
30
|
+
as standard input and never opened; `-d skip` silently passed a tracked symlink to a
|
|
31
|
+
directory), and `dicom`'s binary-match diagnostic branch. `website`'s shape is the right base
|
|
32
|
+
here because this repo tracks one binary, `vendor/cosyte-hl7-0.0.0.tgz`, and a compressed
|
|
33
|
+
stream can contain the em dash bytes by coincidence, which would red the text-only shape with
|
|
34
|
+
no remediation available. **The disclosed cost, stated rather than implied: a tracked *text*
|
|
35
|
+
file holding a NUL byte would be silently exempt, and seeding the tarball itself with a live
|
|
36
|
+
em dash leaves the gate green. That is a miss, not a pass.** mllp has no NUL-bearing text
|
|
37
|
+
file today, so the exclusion currently exempts exactly one file and that file is a genuine
|
|
38
|
+
binary. The at-risk fixture class is not hypothetical, though: git calls the three
|
|
39
|
+
`test/differential/fixtures/*.frame.bin` captures binary too (lone-CR HL7 v2 framing), they
|
|
40
|
+
hold no NUL so they stay in scope, and each was seeded with a live em dash to prove the gate
|
|
41
|
+
actually reads them. Every route by which the scan could print OK without reading its input
|
|
42
|
+
was checked red before this landed, with a seeded fixture per route; the full account is in
|
|
43
|
+
the script header.
|
|
44
|
+
|
|
45
|
+
### Security
|
|
46
|
+
|
|
47
|
+
- **Repo-side PHI commit-scanner (`scripts/phi-scan.ts`), matching the `@cosyte/hl7` pilot.**
|
|
48
|
+
mllp transports HL7 v2 payloads (MLLP wraps HL7 in `VT … FS CR`), so its data fixtures
|
|
49
|
+
(`test/**` `.frame.bin` frames) carry the same PHI shapes hl7's do. The scanner is a direct
|
|
50
|
+
port of hl7's HL7 v2 segment/field-position-aware detector (names, DOB, SSN, MRN/account,
|
|
51
|
+
address, phone, email, and a site-defined `Z…`-segment name backstop) with one transport-layer
|
|
52
|
+
addition: it **unwraps the MLLP frame** (strips the `VT` start-block and trailing `FS CR`
|
|
53
|
+
end-block) before the HL7-aware scan, so a framed fixture's payload is scanned exactly as an
|
|
54
|
+
un-framed `.hl7` file and the framing bytes cannot defeat delimiter/segment detection. The
|
|
55
|
+
unwrap only ever removes framing bytes, so malformed frames (missing end-block, double-framing)
|
|
56
|
+
cannot bypass it; non-HL7 binary byte/buffer fixtures fall through to a conservative dashed-SSN
|
|
57
|
+
+ email pass: no crash, no false positive. Anything not in the synthetic allow-list
|
|
58
|
+
(`scripts/phi-allow-list.txt`) is a hit. Wired like hl7: `pnpm phi-scan`, a `simple-git-hooks`
|
|
59
|
+
`pre-commit` running `phi-scan --staged`, and `run-phi-scan: true` on the CI caller; adds
|
|
60
|
+
`phi-scan-overrides.md` (the audited bypass log) and `test/scripts/phi-scan.test.ts`. Tooling /
|
|
61
|
+
safety only: no runtime or public-API change.
|
|
62
|
+
|
|
63
|
+
### Documentation
|
|
64
|
+
|
|
65
|
+
- **`docs-content/` brought to the full canonical Diátaxis spine.** The sidebar was a flat list
|
|
66
|
+
(`intro`, `framing`, `acks`, `reliability`, `tls`, `limitations`); it is now the canonical spine
|
|
67
|
+
every `@cosyte/*` package shares: Overview → Installation → Quickstart → Core Concepts (`framing`,
|
|
68
|
+
`acks`) → Guides (`reliability`, `tls`) → API Reference (resolver-injected) → Troubleshooting
|
|
69
|
+
(`limitations`). So a developer moving between `@cosyte/hl7` and `@cosyte/mllp` gets one
|
|
70
|
+
navigation. Adds two new tutorials: **Installation** (prerequisites, the optional `@cosyte/hl7`
|
|
71
|
+
peer, a runnable smoke test) and **Quickstart** (the framing round-trip, opt-in tolerance with
|
|
72
|
+
stable warning codes, and the client/server surface). Every example honors the "transport, not
|
|
73
|
+
parsing" boundary. Runnable snippets are now gated by the shared doc/code-agreement harness
|
|
74
|
+
(`docSnippetSuite` from `@cosyte/vitest-config/snippets`, wired in `test/docs-content.test.ts`),
|
|
75
|
+
which extracts each ` ```ts runnable ` block, executes it against the built package, and asserts its
|
|
76
|
+
`// =>` results. A documented example can never silently drift from the shipped surface. Also
|
|
77
|
+
corrects two `intro.md` snippets that referenced non-existent API (`createInMemoryTransport` →
|
|
78
|
+
`InMemoryTransport.pair()`; the receive example's fictional `respond`/`buildAck` → the real
|
|
79
|
+
`createServer({ onMessage })`). Bumps the `@cosyte/vitest-config` devDependency to `^0.0.2` for the
|
|
80
|
+
`/snippets` export. Docs and tests only: no runtime or public-API change.
|
|
81
|
+
- **`docs-content/installation.md` publish-status note corrected (README-ORG-SWEEP).** The Status
|
|
82
|
+
callout said the package was "not yet published to npm" and that the install command was "the shape
|
|
83
|
+
it will take at first publish": stale now that `@cosyte/mllp` is published on npm at `0.0.1` and
|
|
84
|
+
public. Rewritten to state it is published and public, still pre-alpha on the cosyte
|
|
85
|
+
`0.0.x`-until-first-alpha ladder (no API-stability promise), and that `npm install @cosyte/mllp` is
|
|
86
|
+
live. Docs only: no runtime or public-API change.
|
|
87
|
+
|
|
15
88
|
### Fixed
|
|
16
89
|
|
|
90
|
+
- **`ack-from-hl7`: a non-text `encoding` override is now rejected on a `Buffer` inbound too, not just
|
|
91
|
+
the text path, and this fixes a flaky `verify` failure (MLLP-ACK-NONTEXT-CODEC-BUFFER).**
|
|
92
|
+
MLLP-ACK-NONTEXT-CODEC-FRAME (below) spared the `Buffer` path on the belief that a lossy `Buffer`
|
|
93
|
+
override was already caught loudly by the byte-level `MLLP_ACK_CONTROL_ID_NOT_VERBATIM` check. That
|
|
94
|
+
holds for a lossy **charset** codec (`ascii` masking a high bit) but not for a genuinely non-text one.
|
|
95
|
+
A non-text codec (`base64`/`base64url`/`hex`/`utf16le`/`ucs2`) garbles the **inbound** decode:
|
|
96
|
+
`buf.toString("base64")` never begins with `MSH`, so it always routes to the unparseable fallback,
|
|
97
|
+
whose MSA-2 is empty and whose `verifyVerbatimEcho` short-circuits before the NOT_VERBATIM proof can
|
|
98
|
+
run, and then serializes that fallback ACK with the same codec, decoding the ACK text to random
|
|
99
|
+
bytes that ~3–4 % of the time contain a `VT`/`FS` byte and make the strict `encodeFrame` throw a
|
|
100
|
+
nondeterministic `MllpFramingError`. So the path was neither the "loud AE" it was documented to be
|
|
101
|
+
nor caught by any falsifiable check. It was an unreadable frame that sometimes crashed. It surfaced
|
|
102
|
+
as CI flake: the `verify` test asserting a reliable `AE` tripped `encodeFrame` on **both** Node 22
|
|
103
|
+
and Node 24 (the base64 decode is byte-identical across the two: never a runtime divergence, only a
|
|
104
|
+
coin-flip draw of the fallback's generated MSH-10 that landed differently on the two matrix legs of
|
|
105
|
+
one run). `buildMllpAck` now throws a `TypeError` at the boundary for a non-text codec on **any**
|
|
106
|
+
input shape, deterministically. The legitimate byte-level escape hatch is preserved untouched:
|
|
107
|
+
`latin1` (byte-verbatim default for a `Buffer`), `ascii`, `utf8`, and `binary` are still accepted,
|
|
108
|
+
and a lossy charset override on a `Buffer` is still caught loudly by
|
|
109
|
+
`MLLP_ACK_CONTROL_ID_NOT_VERBATIM`. No warning code or other public type changes.
|
|
110
|
+
- **`ack-from-hl7`: a non-text `encoding` override on a text inbound is rejected at the boundary
|
|
111
|
+
instead of emitting a garbage frame (MLLP-ACK-NONTEXT-CODEC-FRAME).** On a `string` / `Hl7Message`
|
|
112
|
+
inbound the resolved codec is used only to serialize the ACK back to bytes. A **text** codec
|
|
113
|
+
(`utf8`/`ascii`/`latin1`) writes the ACK's characters as a byte stream a peer reads back as HL7; a
|
|
114
|
+
**non-text** one does not: `base64`/`base64url`/`hex` reinterpret the ACK *string* as encoded data
|
|
115
|
+
and decode it to unrelated bytes, and `utf16le`/`ucs2` NUL-pad every byte, so the emitted frame is
|
|
116
|
+
wholesale garbage the receiver cannot parse. This was never the silent-corruption class the
|
|
117
|
+
`ascii`-override bleed (above) was: a garbage frame has no readable MSA-2, so the receiver's
|
|
118
|
+
`extractMsaControlId` returns `null` and the ACK-FAILSAFE path already downgrades to a loud `AE`.
|
|
119
|
+
The gap was ergonomic: the unusable ACK was handed back to be written to a socket and found broken
|
|
120
|
+
a round trip later. `buildMllpAck` now throws a `TypeError` at the boundary for a non-text codec on
|
|
121
|
+
a text inbound (exactly as it already does for an unknown `code`), naming the remedy: use a text
|
|
122
|
+
codec, or pass the raw `Buffer`. Scoped to the text path only: on a `Buffer` inbound a codec
|
|
123
|
+
override remains the documented escape hatch, and a lossy one there is still caught loudly by the
|
|
124
|
+
byte-level `MLLP_ACK_CONTROL_ID_NOT_VERBATIM` check. Default `utf8`/`latin1` paths and all-ASCII
|
|
125
|
+
control IDs are unaffected; no warning code or other public type changes.
|
|
126
|
+
- **`scripts/sync-version.mjs` hardened against two latent defects, and gated in CI
|
|
127
|
+
(SYNC-VERSION-HARDENING).** Follow-up hardening on the VERSION-SYNC script; ported byte-identically
|
|
128
|
+
across `hl7`, `x12`, and `mllp`. (1) The version was spliced into `src/index.ts` via
|
|
129
|
+
`String.prototype.replace` with a _replacement string_, which interprets `$&`, `$1`, `` $` ``, etc.,
|
|
130
|
+
so a version like `1.2.3-$&x` would inject the matched text and corrupt the `VERSION` constant while
|
|
131
|
+
exiting 0. The replacement is now a replacer _function_, whose return value is inserted literally.
|
|
132
|
+
(2) The declaration regex was non-global, so `.replace` silently rewrote the _first_ match; a
|
|
133
|
+
column-0 decoy (e.g. inside a comment) ahead of the real declaration could be edited instead. The
|
|
134
|
+
script now matches globally, asserts exactly one declaration, and exits non-zero loudly otherwise.
|
|
135
|
+
Neither defect is reachable through Changesets today and both previously failed loud rather than
|
|
136
|
+
shipping a lying `VERSION`, so this is hardening, not a fix for an observed break. The
|
|
137
|
+
`format`/`format:check` globs now cover `scripts/**/*.mjs` so the script is prettier-gated in CI (it
|
|
138
|
+
was matched by no glob before); widening the gate also reformatted the pre-existing
|
|
139
|
+
`scripts/generate-test-certs.mjs` (cosmetic quote/wrap only). Build tooling only: no runtime or
|
|
140
|
+
public-API change.
|
|
141
|
+
- **`ack-from-hl7`: a lossy `{ encoding: "ascii" }` override on a text inbound can no longer corrupt a
|
|
142
|
+
control ID silently (MLLP-ACK-ASCII-OVERRIDE-BLEED).** The residual path the double-encode fix below
|
|
143
|
+
did not close. `MLLP_ACK_CONTROL_ID_UNVERIFIABLE` originally flagged a text inbound by inspecting the
|
|
144
|
+
**emitted** MSA-2 bytes for a non-ASCII value, a proxy with a blind spot on a lossy override. Node's
|
|
145
|
+
`ascii` codec truncates a code unit to its low 8 bits, so a control-ID code unit above `0xFF`, e.g.
|
|
146
|
+
`U+0153` (`œ`, what a windows-1252 decode yields for a `0x9C` wire byte), is masked *into* the ASCII
|
|
147
|
+
byte range (`0x53`, `'S'`). The emitted MSA-2 is then all-ASCII, the proxy stayed silent, and a
|
|
148
|
+
positive `AA` went out echoing a **different** control ID the sender cannot correlate (ACK timeout →
|
|
149
|
+
resend → **duplicate clinical message**). The check now reads the MSA-2's **pre-encoding code units**
|
|
150
|
+
instead of the emitted bytes, so a non-ASCII code unit is flagged whatever the codec did to the byte,
|
|
151
|
+
a strict superset of the old test (encoding ASCII code units can never produce a non-ASCII byte), so
|
|
152
|
+
the default `utf8` text path is unchanged and all-ASCII control IDs stay quiet. No public-surface
|
|
153
|
+
change; the warning still carries byte/code-unit lengths only (PHI discipline) and names the same
|
|
154
|
+
remedy: pass the raw `Buffer`.
|
|
155
|
+
|
|
156
|
+
- **`ack-from-hl7`: the `string`/`Hl7Message` overload no longer double-encodes a high-bit control ID
|
|
157
|
+
silently.** `buildMllpAck` re-encodes a decoded-text inbound with the JS-native `utf8` default, so
|
|
158
|
+
`buildAckAA(payload.toString("latin1"))` on a control ID of `A <0x8B> B` (legal under `MSH-18` =
|
|
159
|
+
`8859/1`) emitted MSA-2 as `A <0xC2 0x8B> B`, a *different* control ID. The sender keyed its
|
|
160
|
+
in-flight store on `0x8B`, could not match the ACK, timed out, and resent a **duplicate clinical
|
|
161
|
+
message**. The `MLLP_ACK_CONTROL_ID_NOT_VERBATIM` guard could not see it: on a text inbound it
|
|
162
|
+
re-derives the inbound bytes from the same text with the same codec, so the comparison is a
|
|
163
|
+
tautology. The encoding cannot be fixed from decoded text (a string does not remember its codec), so
|
|
164
|
+
this is an **API-shape** fix: the text path now emits a new, distinct warning code,
|
|
165
|
+
**`MLLP_ACK_CONTROL_ID_UNVERIFIABLE`** (exported from `@cosyte/mllp/ack-from-hl7`), whenever the
|
|
166
|
+
emitted MSA-2 holds a non-ASCII byte on a `string`/`Hl7Message` inbound, a *cannot-verify* signal,
|
|
167
|
+
deliberately separate from the `Buffer`-path *proof-of-mismatch*. An all-ASCII control ID stays
|
|
168
|
+
quiet; the warning carries byte lengths only (PHI discipline) and names the remedy: pass the raw
|
|
169
|
+
`Buffer`. Found by the 4th MLLP-ACK-UTF8 conformance-refuter.
|
|
170
|
+
|
|
17
171
|
- **Removed ten orphan gitlinks from `.claude/worktrees/`.** A commit captured local agent worktree
|
|
18
172
|
state as ten mode-`160000` gitlinks with no `.gitmodules` entry, pointing at objects that never
|
|
19
173
|
existed in this repo. This is the ADR 0004 failure mode that `iac` and `pathways` each produced;
|
|
20
174
|
it went unnoticed here. `.claude/worktrees/` is now gitignored so it cannot recur. Repo hygiene
|
|
21
|
-
only
|
|
175
|
+
only: nothing in `src/`, `test/`, or the published tarball is affected.
|
|
22
176
|
|
|
23
177
|
- **The Release workflow can actually start.** `.github/workflows/release.yml` calls the shared
|
|
24
178
|
`cosyte/.github` pipeline, which requests `contents`/`id-token`/`pull-requests: write`, but declared
|
|
25
|
-
no `permissions:` of its own
|
|
179
|
+
no `permissions:` of its own, so it inherited the repo default of `contents: read`. A called
|
|
26
180
|
workflow may only downgrade the caller's `GITHUB_TOKEN`, never escalate it, so GitHub rejected the
|
|
27
181
|
workflow at startup (~1s, no jobs, no logs). Every Release run from June 2026 until now failed this
|
|
28
182
|
way, unnoticed, because a `startup_failure` produces no logs to read. The caller job now declares
|
|
29
|
-
the three scopes explicitly. CI-only
|
|
183
|
+
the three scopes explicitly. CI-only: no runtime or API change.
|
|
30
184
|
|
|
31
|
-
- **`buildRawAck` and the server's auto-ACK path said "AA
|
|
185
|
+
- **`buildRawAck` and the server's auto-ACK path said "AA, I've got it" for messages they could not
|
|
32
186
|
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
|
|
34
|
-
it never read
|
|
187
|
+
message; when the ACK names a control ID the sender cannot match (or names one of several messages
|
|
188
|
+
it never read), the sender times out and resends, committing a **duplicate clinical message** (or
|
|
35
189
|
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
|
|
37
|
-
pre-existing on `main`
|
|
190
|
+
builder and the default `autoAck: 'AA'` path did not, and four peer-reachable inputs (all
|
|
191
|
+
pre-existing on `main`) produced a positive `MSA|AA|`: (1) an inbound with an empty MSH-10, (2)
|
|
38
192
|
**two concatenated `MSH` messages** in one frame (an `AA` naming only the first, message 2 silently
|
|
39
193
|
unacknowledged), (3) a **`BOM`/`SP`/`TAB` before `MSH`** (the junk shares the MSH's segment line, so
|
|
40
194
|
`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
|
|
195
|
+
verified over a real socket, a **raw `VT` inside a payload**: the decoder discards the accumulated
|
|
42
196
|
bytes (`MLLP_TRAILING_BYTES`) and delivers only the *fragment* after it, which the server auto-ACKed
|
|
43
197
|
`MSA|AA|`: the clinical message destroyed and positively acknowledged. A requested positive code
|
|
44
198
|
(`AA`/`CA`) is now **downgraded** to `AE`/`CE` whenever the payload cannot carry a correlatable
|
|
45
|
-
positive ACK
|
|
199
|
+
positive ACK: no readable `MSH`, an empty MSH-10, a batch/concatenated-message shape
|
|
46
200
|
(`FHS`/`BHS`/`BTS`/`FTS` or a second `MSH`), or, on the server path, a frame the decoder flagged
|
|
47
201
|
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
|
|
202
|
+
message readable, re-bases on a located `MSH`, or parses a batch. Batch ACK stays its own unbuilt
|
|
49
203
|
feature (`MLLP-BATCH`), a loud non-positive answer. The wire downgrade in `buildRawAck` protects any
|
|
50
204
|
direct caller (defense in depth); the server re-checks the same condition so the downgrade is
|
|
51
205
|
**observable**, emitting a PHI-safe `'nack'` event with a new `reason`
|
|
52
206
|
(`'handler-rejected' | 'uncorrelatable-inbound' | 'discarded-bytes'`), never the payload or control
|
|
53
207
|
ID. New exports: `rawAckUncorrelatable(payload)` and the `NackReason` type. As part of this,
|
|
54
208
|
`MLLP_TRAILING_BYTES` is now **reserved for the mid-payload `VT` discard** (a frame-scoped signal)
|
|
55
|
-
and is no longer emitted
|
|
209
|
+
and is no longer emitted (nor mis-attributed to the *next* frame) for an inter-frame stray byte
|
|
56
210
|
under `allowFsOnly`, which `MLLP_FS_WITHOUT_CR` already reports; without that, a good message
|
|
57
211
|
pipelined after a stray-byte frame would have been wrongly downgraded to `AE` (caught by the
|
|
58
212
|
conformance gate).
|
|
59
213
|
- **The MSH-10 scan ran past the segment terminator and returned the patient's MRN as the
|
|
60
214
|
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**
|
|
62
|
-
10 fields, which is malformed, but is precisely what a broken peer sends
|
|
215
|
+
field separators without ever stopping at `CR`/`LF`. On a **truncated MSH** (one with fewer than
|
|
216
|
+
10 fields, which is malformed, but is precisely what a broken peer sends), the count therefore ran
|
|
63
217
|
*past the segment terminator* and kept counting inside the next segment. Given
|
|
64
218
|
`MSH|^~\&|EPIC|HOSP|MIRTH|LAB` + `PID|1||MRN00042|…`, the "MSH-10" it returned was **`PID-3`: the
|
|
65
219
|
patient's medical record number**. `MllpClient.send()` calls this on every outbound payload in
|
|
66
220
|
controlId mode, so that value became the correlator's key, and was carried into
|
|
67
221
|
`MllpTimeoutError.messageControlId` and the `MLLP_ACK_UNMATCHED_CONTROL_ID` /
|
|
68
|
-
`MLLP_ACK_AFTER_TIMEOUT` warnings
|
|
222
|
+
`MLLP_ACK_AFTER_TIMEOUT` warnings: **a patient identifier in a log line, and a mis-read one at
|
|
69
223
|
that**, plus a correlation key that is not the control ID the peer will ACK. Present since Phase 5
|
|
70
224
|
and untouched by MLLP-CORRELATOR-ASCII, which fixed the *decode* of this scan but not its
|
|
71
225
|
*bounds*. Fixed: the scan is now `readMshSegment`, which bounds the MSH at its terminator before
|
|
@@ -74,37 +228,37 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
|
|
|
74
228
|
- **`ack-from-hl7` could not echo a control ID verbatim, so a cosyte client could not correlate a
|
|
75
229
|
cosyte server's ACK (MLLP-ACK-UTF8).** `buildMllpAck` decoded the inbound through the peer
|
|
76
230
|
parser's charset machinery and re-encoded the ACK through a hardcoded **`utf8`**. The two are not
|
|
77
|
-
inverses. A control-ID byte `0x8B`
|
|
78
|
-
`MLLP-CORRELATOR-ASCII` had just fixed on the client
|
|
231
|
+
inverses. A control-ID byte `0x8B` (legal under an `MSH-18` of `8859/1`, and the exact case
|
|
232
|
+
`MLLP-CORRELATOR-ASCII` had just fixed on the client) came back out of MSA-2 as the **two** bytes
|
|
79
233
|
`0xC2 0x8B`: a *different* control ID, so HL7 v2.5.1 §2.9.2.2's verbatim-echo requirement was
|
|
80
234
|
violated. The client keys its in-flight store on the raw bytes it sent, so it could not match that
|
|
81
235
|
ACK: the send never settled → ACK timeout → resend → **duplicate clinical message**. This was the
|
|
82
236
|
third and last of the three call sites that each re-derived "read the control ID" independently and
|
|
83
237
|
each got it wrong differently.
|
|
84
|
-
- `Buffer` input is now decoded as **`latin1`** and the ACK re-encoded with the same codec
|
|
238
|
+
- `Buffer` input is now decoded as **`latin1`** and the ACK re-encoded with the same codec, one
|
|
85
239
|
symmetric choice, so the round-trip is the exact identity for any inbound bytes. `latin1` is the
|
|
86
240
|
only codec for which that holds: `ascii` masks the high bit, `utf8` folds invalid sequences onto
|
|
87
241
|
`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
|
|
242
|
+
**windows-1252** (`0x8B` → `U+2039`), which does not round-trip `0x80`–`0x9F` at all, so the
|
|
89
243
|
decode had to be taken away from the charset-aware parser and done on the bytes directly.
|
|
90
244
|
`string` / `Hl7Message` input keeps its `utf8` default (the caller already chose the decode).
|
|
91
|
-
- The MSH read is now **one** implementation
|
|
245
|
+
- The MSH read is now **one** implementation, `readMshSegment` in `src/internal/control-id.ts`,
|
|
92
246
|
genuinely *called* by all three consumers: the client correlator, `buildRawAck`, and
|
|
93
247
|
`buildMllpAck`. `buildRawAck` previously re-derived its own read
|
|
94
248
|
(`payload.toString("latin1").split("\r")`, hunting for an `MSH` anywhere in the payload), and
|
|
95
249
|
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
|
|
250
|
+
the PID's MRN while `buildRawAck` echoed an empty MSA-2; on a payload with a **leading `CR`** (
|
|
251
|
+
which the MLLP decoder passes straight through) `buildRawAck` echoed MSH-10 correctly while the
|
|
98
252
|
correlator, requiring `MSH` at byte 0, gave up. Every such disagreement is an ACK the sender
|
|
99
253
|
cannot match.
|
|
100
254
|
- They now agree at the **tolerant** fixed point, not a lossy one: `readMshSegment` **locates** the
|
|
101
255
|
`MSH` (the first `CR`/`LF`-delimited segment starting with `MSH`) instead of demanding it at byte
|
|
102
256
|
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
|
|
257
|
+
is plainly present, and *then* bounds the field scan at that segment's terminator. Both rules
|
|
104
258
|
are needed and neither may be traded for the other. An interim version of this fix forced
|
|
105
259
|
agreement by requiring `MSH` at byte 0 everywhere, which "resolved" the leading-`CR`
|
|
106
260
|
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
|
|
261
|
+
`AA` with an empty MSA-2, **silently**, for a message whose MSH-10 was there to read, a
|
|
108
262
|
tolerance regression that manufactured the very duplicate-message failure this item exists to
|
|
109
263
|
close. A lenient reader may never drop data that is present (Postel's Law). `buildMllpAck` strips **leading
|
|
110
264
|
segment terminators only** before parsing, for the same reason and no further: `parseHL7` requires
|
|
@@ -112,16 +266,16 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
|
|
|
112
266
|
so dropping it can hide nothing.
|
|
113
267
|
- **An HL7 batch (§2.10.3) is still refused, loudly.** `buildMllpAck` does not implement batch ACK,
|
|
114
268
|
so an `FHS`/`BHS` envelope falls through to `parseHL7`'s `NO_MSH_SEGMENT` and out into the
|
|
115
|
-
warned, non-positive `AE` fallback
|
|
269
|
+
warned, non-positive `AE` fallback, exactly as before this item. An interim version of the fix
|
|
116
270
|
above re-based the payload on the *located* `MSH`, which skipped the batch envelope: the builder
|
|
117
271
|
then parsed only message 1, silently discarded every later `MSH` and the `BTS`/`FTS`, and returned
|
|
118
272
|
a positive **`AA` correlated to message 1 with zero warnings** for a batch whose messages 2..N it
|
|
119
|
-
had never read
|
|
273
|
+
had never read, telling the sender the whole batch was accepted. A positive ACK for a message
|
|
120
274
|
nobody looked at is what the commit contract exists to make structurally impossible. Batch ACK is
|
|
121
275
|
its own feature; it is not something to arrive at by accident via a byte-offset helper.
|
|
122
276
|
- **`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)
|
|
124
|
-
offset 3 of the MSH segment defines it
|
|
277
|
+
(MLLP-ACK-UTF8, sibling).** MSH-1 *is* the field separator (HL7 v2.5.1 §2.5.4), the byte at
|
|
278
|
+
offset 3 of the MSH segment defines it, and the client-side scanners had always read it
|
|
125
279
|
dynamically. `buildRawAck` split on a hardcoded `|`, so a `!`-delimited message yielded one field
|
|
126
280
|
and **every** echoed field came back empty: the ACK went out as `MSA|AA|` with **no correlation id
|
|
127
281
|
at all**, unmatchable by construction. It now reads MSH-1 and echoes the inbound's own MSH-1/MSH-2,
|
|
@@ -134,21 +288,21 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
|
|
|
134
288
|
|
|
135
289
|
- **The client's ACK correlator masked the high bit out of the correlation key
|
|
136
290
|
(MLLP-CORRELATOR-ASCII).** `extractMshControlId` / `extractMsaControlId` decoded MSH-10 / MSA-2
|
|
137
|
-
with `ascii` (`byte & 0x7f`)
|
|
291
|
+
with `ascii` (`byte & 0x7f`), the same class of bug the Phase 10 entry below fixed in
|
|
138
292
|
`buildRawAck`, left behind on the client side since Phase 5, so the server's MSH-10 → MSA-2 echo
|
|
139
293
|
and the client's read-back did not agree on what a control ID *is*. The extracted string **is**
|
|
140
294
|
the correlator's key (live store, graveyard, ACK lookup), so a lossy decode is a lossy key: the
|
|
141
295
|
two legal, distinct control IDs `MSGÉ1` and `MSGI1` (`0xC9 & 0x7F === 0x49`) collapsed onto one
|
|
142
296
|
key, the second `enqueue()` overwrote the first in the `Map`, and the first send could never be
|
|
143
297
|
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
|
|
298
|
+
`MLLP_ACK_AFTER_TIMEOUT` observers and `MllpTimeoutError.messageControlId`, an ID that was never
|
|
145
299
|
on the wire, misdirecting the operator tracing a lost message. Reachable when MSH-18 declares a
|
|
146
300
|
non-ASCII charset (e.g. `8859/1`). Fixed: both extractors decode `latin1` (1:1 byte↔code-unit, so
|
|
147
301
|
distinct bytes stay distinct keys and no VT/FS can be synthesized). Six tests added under
|
|
148
302
|
`test/client/correlator-controlid.test.ts`, each failing under the old decode, one of them a
|
|
149
303
|
cross-path round-trip pinning `buildRawAck`'s echo and the client extractors to the same key.
|
|
150
304
|
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
|
|
305
|
+
only for the `|`-delimited messages `buildRawAck` supported: it still hardcoded `|` where the
|
|
152
306
|
extractors read the separator from MSH-1, and the `ack-from-hl7` subpath still round-tripped
|
|
153
307
|
control IDs through `utf8`. Both of those were left pre-existing then, and are **closed by
|
|
154
308
|
MLLP-ACK-UTF8** (above); the scanners are now a single shared implementation.
|
|
@@ -156,36 +310,36 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
|
|
|
156
310
|
- **A peer could crash the server with one high-bit byte, and corrupt the ACK control ID
|
|
157
311
|
(Phase 10).** `buildRawAck` decoded the inbound message with `ascii`, which masks the high bit
|
|
158
312
|
(`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`
|
|
160
|
-
*different* id
|
|
313
|
+
**verbatim** (HL7 v2.5.1 §2.9.2.2), but a control-ID byte `0x8B` silently became `0x0B` (a
|
|
314
|
+
*different* id), breaking the sender's own ACK correlation for any non-ASCII charset. **Safety:**
|
|
161
315
|
`0x8B → 0x0B` is a **VT** and `0x9C → 0x1C` is an **FS**, so `ascii` *synthesized framing
|
|
162
316
|
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
|
|
317
|
+
made the ACK payload contain a real VT/FS, which `encodeFrame` (strict) rejected, and that throw
|
|
164
318
|
escaped the `void`-ed `_sendCommitAck`, **crashing the whole server on peer-controlled input with
|
|
165
319
|
no consumer bug at all**, and suppressing the fail-safe ACK. Fixed: `buildRawAck` uses `latin1`
|
|
166
320
|
(byte-exact; a delivered payload cannot itself contain VT/FS, and `latin1` cannot synthesize
|
|
167
|
-
one), and `_dispatchAck` is now **total
|
|
321
|
+
one), and `_dispatchAck` is now **total**: a frame failure (still reachable via a caller's
|
|
168
322
|
`autoAck: fn`) surfaces as a connection `'error'` and the message goes un-ACKed (fail-safe: the
|
|
169
323
|
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
|
|
324
|
+
- **Anything throwing on the receive path crashed the whole process, four routes, all closed
|
|
171
325
|
(Phase 10).** `FrameReader.push()` runs synchronously inside the transport's data callback, which
|
|
172
326
|
on a real socket **is** the `'data'` listener, so any throw there is an **uncaught exception** that
|
|
173
|
-
kills the process
|
|
327
|
+
kills the process, every other connection and every in-flight durable commit with it. The
|
|
174
328
|
conformance gate refuted the fix three times, each round surfacing a route the previous fix had
|
|
175
|
-
missed
|
|
176
|
-
1. **The decoder's own throw
|
|
329
|
+
missed, **four** in total:
|
|
330
|
+
1. **The decoder's own throw**: `Connection` fed `push(chunk)` with no `try`/`catch`. Reachable on
|
|
177
331
|
a **default server from a single byte**: `SERVER_DEFAULT_FRAMING` leaves `allowMissingLeadingVt`
|
|
178
332
|
off, so any non-whitespace byte where a `VT` was expected threw `MLLP_MISSING_LEADING_VT`
|
|
179
333
|
(`MLLP_FRAME_TOO_LARGE` reached the same path). One stray keepalive character from a real
|
|
180
334
|
interface engine was enough.
|
|
181
|
-
2. **`emit('error')` with no listener
|
|
335
|
+
2. **`emit('error')` with no listener**: Node raises `ERR_UNHANDLED_ERROR`, and that throw happened
|
|
182
336
|
*inside the catch block added for (1)*, escaping by the identical route. `MllpServer`/
|
|
183
337
|
`MllpClient` each attach an `'error'` listener, which masked it; `Connection` is a public export
|
|
184
338
|
and need not.
|
|
185
|
-
3. **A throwing `'message'`/`'warning'` subscriber
|
|
339
|
+
3. **A throwing `'message'`/`'warning'` subscriber**: `onFrame` dispatches synchronously inside
|
|
186
340
|
`push()`, so an ordinary consumer bug (a metrics tap, a logger) unwound through the socket
|
|
187
341
|
handler too.
|
|
188
|
-
4. **The five lifecycle emits
|
|
342
|
+
4. **The five lifecycle emits**: `destroy()` → `_transition()` → `emit('stateChange'|'close'|…)`
|
|
189
343
|
runs *inside* the catch block added for (1), and a throw raised inside a `catch` is **not**
|
|
190
344
|
caught by that block. A throwing `'close'` subscriber plus one stray byte still killed the
|
|
191
345
|
process, four frames up.
|
|
@@ -196,11 +350,11 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
|
|
|
196
350
|
|
|
197
351
|
Now: a fatal framing error surfaces as a frozen `'error'` event (`phase: 'receive'`,
|
|
198
352
|
`connectionCause: 'framing-fatal'`, the `MllpFramingError` preserved as `cause` so the stable
|
|
199
|
-
`code`/`byteOffset` survive) and **only that connection** is destroyed
|
|
353
|
+
`code`/`byteOffset` survive) and **only that connection** is destroyed. A server drops the one bad
|
|
200
354
|
peer and keeps serving. Every `'error'` emit is guarded by `listenerCount` and wrapped. Subscriber
|
|
201
|
-
throws are contained per-subscriber at the dispatch site
|
|
355
|
+
throws are contained per-subscriber at the dispatch site, what WARN-06 always promised but only
|
|
202
356
|
half-implemented (the `onWarning` *option* was guarded; the event broadcast was not). A fatal
|
|
203
|
-
framing error is also reported **exactly once** now
|
|
357
|
+
framing error is also reported **exactly once** now: `destroy(err)` forwards the reason to
|
|
204
358
|
`transport.destroy(err)`, which made a real socket echo it back through `_onTransportError` and
|
|
205
359
|
emit a second, causeless `'error'`, double-counting on an alerting dashboard. The
|
|
206
360
|
connection is destroyed rather than resynchronized deliberately: after a throw the reader's position
|
|
@@ -208,27 +362,27 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
|
|
|
208
362
|
message gets silently mis-split. The existing suites missed all of this because the in-memory
|
|
209
363
|
transport wraps delivery in `try`/`finally`, re-routing the throw to the *writer*; only a real
|
|
210
364
|
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)
|
|
365
|
+
sockets) and `test/connection/receive-containment.test.ts` (drives the data callback directly),
|
|
212
366
|
both verified to fail without the fixes.
|
|
213
367
|
- **A fatal framing error triggered an unbounded reconnect storm (Phase 10).**
|
|
214
368
|
`isTransientConnectionError` switches on `err.code` and fell through to `default: return true`, so a
|
|
215
369
|
`MllpFramingError` was classified **transient**. `createStarterClient` (where `autoReconnect`
|
|
216
|
-
defaults **on**) therefore retried forever against a peer that was not speaking MLLP
|
|
217
|
-
a health check, a wrong-port misconfiguration
|
|
370
|
+
defaults **on**) therefore retried forever against a peer that was not speaking MLLP (an HTTP probe,
|
|
371
|
+
a health check, a wrong-port misconfiguration) with the backoff hammering an interface engine that
|
|
218
372
|
was already misconfigured. `MLLP_*` codes are now **permanent**, alongside the TLS classes and for
|
|
219
373
|
the same reason: every reconnect meets the same bytes.
|
|
220
374
|
- **A throwing `'message'` observer suppressed the ACK (Phase 10).** `MllpServer` emits `'message'` to
|
|
221
375
|
observers *before* ACK dispatch (D-03), so an observer that threw aborted the handler before the ACK
|
|
222
|
-
was sent
|
|
376
|
+
was sent. One broken logger silently turned every message into a no-ACK, and every sender resent
|
|
223
377
|
forever with nothing to diagnose it by. The emit is now contained: the throw surfaces on `'error'`
|
|
224
378
|
and the commit contract proceeds untouched. The ACK decision belongs to `ServerOptions.onMessage`
|
|
225
379
|
(the durable-commit step), not to a metrics tap.
|
|
226
380
|
- **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
|
-
|
|
381
|
+
workflow drives Changesets with `version: pnpm run version`, but no `version` script existed.
|
|
382
|
+
It failed with `ERR_PNPM_NO_SCRIPT`, so the "Version Packages" PR could never be opened. Added
|
|
229
383
|
`version` (`changeset version` → `scripts/sync-version.mjs` → `prettier --write`).
|
|
230
384
|
- **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
|
|
385
|
+
`"0.0.0"` in `src/index.ts`, while `changeset version` bumps only `package.json`. The published
|
|
232
386
|
`0.0.1` would have exported `"0.0.0"`. `scripts/sync-version.mjs` now rewrites the constant from
|
|
233
387
|
`package.json` inside the `version` script, and `test/sanity.test.ts` compares the export against
|
|
234
388
|
`package.json` rather than asserting a hardcoded literal against a hardcoded literal (the old
|
|
@@ -238,14 +392,14 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
|
|
|
238
392
|
equality check against any other version into a compile error. Now `VERSION: string`.
|
|
239
393
|
- **Docs accuracy (Phase 10).** Found by the conformance gate, which refuted the first cut of the
|
|
240
394
|
new guide:
|
|
241
|
-
- `docs-content/intro.md` described the decoder as liberal outright. It is **strict by default
|
|
395
|
+
- `docs-content/intro.md` described the decoder as liberal outright. It is **strict by default**:
|
|
242
396
|
tolerance is opt-in per flag, and it is `MllpServer` that ships tolerant defaults (`allowFsOnly`,
|
|
243
397
|
`allowLfAfterFs`, `allowLeadingWhitespace`; `allowMissingLeadingVt` stays off even there).
|
|
244
398
|
- `MLLP_TRAILING_BYTES` is **not** benign junk between frames. It fires on a `VT` appearing
|
|
245
|
-
*mid-payload*
|
|
246
|
-
message
|
|
399
|
+
*mid-payload* (which **discards the accumulated partial payload**, i.e. a **truncated**
|
|
400
|
+
message) and on a stray byte after `FS` under `allowFsOnly`. Now documented as something to
|
|
247
401
|
alert on rather than ignore.
|
|
248
|
-
- **`close()` does not drain in-flight messages
|
|
402
|
+
- **`close()` does not drain in-flight messages**: it *rejects* them with
|
|
249
403
|
`MllpConnectionError({ phase: 'close' })`. No drain hook is wired to the `DRAINING` state, so
|
|
250
404
|
`drainTimeoutMs` does not currently bound an in-flight ACK wait on the client. A message in
|
|
251
405
|
flight at shutdown is an **unknown**, not a failure: the receiver may have committed it. Now
|
|
@@ -259,15 +413,15 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
|
|
|
259
413
|
inbound declared no usable MSH-2, the builder fell back to the HL7 default encoding characters
|
|
260
414
|
`^~\&` **without checking them against MSH-1**. For an inbound declaring MSH-1 = `^` (or `~`, `\`,
|
|
261
415
|
`&`), 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
|
|
416
|
+
**empty MSH-2** and every later MSH field shifted by one (§2.5.4, §2.16: the delimiters must be
|
|
263
417
|
distinct). Fixed: `buildRawAck` substitutes only the one colliding **encoding character** and keeps
|
|
264
418
|
the inbound's **field separator** unchanged.
|
|
265
419
|
- Keeping the field separator is the load-bearing part. The field separator is the only byte that
|
|
266
420
|
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
|
|
421
|
+
inbound MSH *on* it). An interim fix instead switched the ACK's field separator to `|`, and
|
|
268
422
|
since a `|` inside an `^`-delimited message's MSH-10 is *ordinary data* (§2.5.4), an MSH-10 of
|
|
269
423
|
`ID|X` went out as `MSA|AA|ID|X`, which a receiver reads back as **`ID`**: silently **truncated**.
|
|
270
|
-
Truncated is worse than empty
|
|
424
|
+
Truncated is worse than empty: `ID` is *plausible*, so it can match a **different** in-flight
|
|
271
425
|
send and falsely settle it, losing one clinical message (its `send()` resolves though it was
|
|
272
426
|
never acknowledged) and duplicating another (the one actually acknowledged stays in flight and
|
|
273
427
|
resends). Substituting the encoding character avoids all of this: the ACK stays under the
|
|
@@ -278,17 +432,24 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
|
|
|
278
432
|
|
|
279
433
|
### Added
|
|
280
434
|
|
|
281
|
-
-
|
|
435
|
+
- **Trademark notice (`TRADEMARKS.md`).** This package names third-party systems to describe what it
|
|
436
|
+
interoperates with; the notice records that cosyte is not affiliated with, endorsed by, or
|
|
437
|
+
sponsored by any of them, that every reference is descriptive, and that the built-in profiles are
|
|
438
|
+
authored from public sources only. Added to `files` so it ships inside the published tarball, not
|
|
439
|
+
just on GitHub. Documentation only: no runtime or API change.
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
- **`MLLP_ACK_CONTROL_ID_NOT_VERBATIM` (MLLP-ACK-UTF8).** A new stable warning code,
|
|
282
443
|
`ack-from-hl7`-scoped, emitted in `MllpAck.warnings`, not through the framing registry (13 codes
|
|
283
444
|
total now). `buildMllpAck` **verifies** every ACK it builds against the very byte-level scanners the
|
|
284
445
|
`@cosyte/mllp` client uses to correlate, and warns when MSA-2 is not byte-identical to the inbound
|
|
285
446
|
MSH-10 (HL7 v2.5.1 §2.9.2.2). The warning reports the two byte **lengths** and withholds the
|
|
286
|
-
field values
|
|
287
|
-
emitted
|
|
288
|
-
mismatched ACK beats silence
|
|
447
|
+
field values. MSH-10 is inbound payload content and a warning goes to a log. The ACK is still
|
|
448
|
+
emitted. A
|
|
449
|
+
mismatched ACK beats silence. But the mismatch can no longer pass unremarked, because a
|
|
289
450
|
non-verbatim MSA-2 *is* an ACK the sender cannot match. It fires for an `encoding` override that
|
|
290
451
|
cannot round-trip the inbound bytes, and for an inbound declaring non-default delimiters or a
|
|
291
|
-
whitespace-padded control ID
|
|
452
|
+
whitespace-padded control ID, cases `@cosyte/hl7`'s builder structurally cannot represent (it
|
|
292
453
|
always emits `|^~\&`, and trims field whitespace). `buildRawAck` is parser-free and has neither
|
|
293
454
|
limit, so it remains the answer for a non-default-delimiter peer.
|
|
294
455
|
|
|
@@ -296,7 +457,7 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
|
|
|
296
457
|
the wire bytes are decoded before `buildMllpAck` ever sees them, so it re-encodes the caller's text
|
|
297
458
|
with the same codec it decoded it with: the codec cancels on both sides and a codec-induced
|
|
298
459
|
mismatch is **structurally invisible**. `buildAckAA(payload.toString("latin1"))` on a high-bit
|
|
299
|
-
control ID emits `0xC2 0x8B`
|
|
460
|
+
control ID emits `0xC2 0x8B` (a different control ID) and warns about nothing. That double-encode
|
|
300
461
|
is pre-existing (byte-identical on the previous release line) and is tracked separately; the guard
|
|
301
462
|
cannot be grown to catch it, because by then the bytes are gone. What was *new* and is now fixed is
|
|
302
463
|
the **claim** that it could not happen: `docs-content/acks.md`, `docs-content/limitations.md`, and
|
|
@@ -312,7 +473,7 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
|
|
|
312
473
|
(clean → typecheck → lint → test → build → `attw`) is green; `changeset version` consumes the
|
|
313
474
|
pending changesets to exactly **`0.0.1`**; the dry-run packs 24 files (`dist/` + README +
|
|
314
475
|
LICENSE + CHANGELOG) with public access and no `src/`, `test/`, or `vendor/` leakage;
|
|
315
|
-
`pack:docs` produces both docs artifacts. **Nothing was published
|
|
476
|
+
`pack:docs` produces both docs artifacts. **Nothing was published**. The first real publish
|
|
316
477
|
is a human gate.
|
|
317
478
|
- **Full `docs-content/` transport guide.** New pages: **Framing & tolerance** (wire format, the
|
|
318
479
|
opt-in tolerance flags with a `FrameReader`-vs-`MllpServer` default table, the stable
|
|
@@ -329,51 +490,51 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
|
|
|
329
490
|
- **Real-world interop, differential conformance & PHI/observability audit (Phase 9).**
|
|
330
491
|
- **PHI hardening (framing).** `MllpFramingError.snippet` no longer carries a run of payload content
|
|
331
492
|
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)
|
|
493
|
+
accumulated payload bytes into `snippet` (the too-large frame is a full HL7 message), now empty (the
|
|
333
494
|
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
|
|
495
|
+
reachable from `client.send()`) copied up to 64 payload bytes around the offending delimiter, now
|
|
335
496
|
just the single offending delimiter byte (a VT/FS control byte the `code` already names). Every
|
|
336
497
|
framing throw now carries at most the single framing-boundary byte that broke the structure, never a
|
|
337
498
|
payload run; the `snippet` PHI contract is documented on the field. No public-API change.
|
|
338
|
-
- **Differential harness** (`test/differential/`)
|
|
499
|
+
- **Differential harness** (`test/differential/`): byte-parity with the Google Cloud Healthcare MLLP
|
|
339
500
|
adapter and Mirth/NextGen Connect (both R1). Tier 1 (always on) asserts decode + `encodeFrame`
|
|
340
501
|
byte-parity against canonical R1 golden frames and ACK correlation; Tier 2 (`MLLP_DIFF_ADAPTER`
|
|
341
502
|
opt-in) checks a live adapter and skips cleanly when unset, so `verify` stays green.
|
|
342
|
-
- **Quirk corpus** (`test/conformance/`)
|
|
503
|
+
- **Quirk corpus** (`test/conformance/`): a realistic multi-segment HL7 message driven through each
|
|
343
504
|
§3 real-world deviation, asserting the exact warning code/typed error and byte-identical payload
|
|
344
505
|
recovery; the lenient decoder never throws except the sanctioned `MLLP_FRAME_TOO_LARGE`.
|
|
345
|
-
- **PHI-safety property suite
|
|
506
|
+
- **PHI-safety property suite**: generative proof (mutation-checked) that no framing diagnostic ever
|
|
346
507
|
echoes payload content, including the oversized path.
|
|
347
508
|
- **Test-infra:** the pre-existing `test/server/*` suites now use the shared
|
|
348
509
|
`test/helpers/tracked-servers.ts` (`must()` + `makeServerTracker()`) instead of copy-pasted helpers.
|
|
349
|
-
- **Scope note:** the remaining Phase 9 roadmap acceptance items
|
|
350
|
-
detection and (d) fuzz chunk-boundary adversaries
|
|
510
|
+
- **Scope note:** the remaining Phase 9 roadmap acceptance items, (c) keepalive / half-open
|
|
511
|
+
detection and (d) fuzz chunk-boundary adversaries, were already delivered in earlier phases
|
|
351
512
|
(`socket.setKeepAlive` in `src/server/server.ts`; the byte-at-a-time `randomChunks` /
|
|
352
513
|
`split(1)` generators in `test/property/fuzz.property.test.ts`), so Phase 9 legitimately
|
|
353
514
|
narrows to the PHI snippet audit + differential harness + quirk corpus.
|
|
354
515
|
- **TLS / MLLPS hardening (Phase 8).** `TlsTransport` (wraps `tls.TLSSocket`, maps `onConnect` to
|
|
355
516
|
`'secureConnect'`) joins `NetTransport` as a first-class `Transport`. Client: `ClientOptions.tls?:
|
|
356
|
-
TlsOptions | true
|
|
517
|
+
TlsOptions | true`: verification **on by default**; the only opt-out is the loud
|
|
357
518
|
`allowUnverified` flag, which emits a frozen `'securityWarning'` (`MLLP_TLS_VERIFY_DISABLED`) +
|
|
358
519
|
`process.emitWarning` on every `secureConnect` (initial connect and every reconnect). Server:
|
|
359
520
|
`ServerOptions.tls?: ServerTlsOptions` with `clientAuth: 'NONE' | 'WANT' | 'MUST'` (ATNA ITI-19
|
|
360
|
-
mutual node authentication)
|
|
521
|
+
mutual node authentication): `'WANT'`/`'MUST'` surface a minimal, content-free `peerCertificate`
|
|
361
522
|
(`{ subjectCN, issuerCN, validTo, authorized }`) on the `'connection'` event; `authorized`
|
|
362
523
|
reports whether the chain was verified against `ca` (under `'WANT'` a certificate can be present
|
|
363
|
-
yet unverified
|
|
524
|
+
yet unverified, never authorize on `subjectCN` alone); `'MUST'` additionally rejects
|
|
364
525
|
unauthorized/missing client certificates. Failed handshakes (incl. rejected mTLS client certs)
|
|
365
526
|
never crash the server: a frozen `'tlsClientError'` event (`{ remoteAddress, remotePort, message,
|
|
366
527
|
code, timestamp }`) is emitted and the server keeps accepting other connections. Both `minVersion`
|
|
367
|
-
default to `'TLSv1.2'
|
|
368
|
-
`'TLSv1.0'/'TLSv1.1'` are not expressible through this API. No bundled cipher list
|
|
528
|
+
default to `'TLSv1.2'`: the IHE ATNA ITI-19 "TLS 1.2 Floor" (BCP195) floor (ITI TF-2 §3.19.6.2.3);
|
|
529
|
+
`'TLSv1.0'/'TLSv1.1'` are not expressible through this API. No bundled cipher list: `ciphers`
|
|
369
530
|
passes through to Node's OpenSSL defaults, which already include both ATNA-mandated ECDHE suites.
|
|
370
531
|
New typed failure modes on `MllpConnectionError.connectionCause`: `'tls-verify'` (certificate
|
|
371
532
|
verification failure) and `'tls-handshake'` (TLS-**protocol**-shaped pre-`secureConnect` failures
|
|
372
|
-
only
|
|
533
|
+
only: `ERR_SSL_*`, `EPROTO`, OpenSSL alert-bearing errors; pure TCP failures like `ECONNREFUSED`
|
|
373
534
|
carry no `connectionCause`, same as plaintext). Both classes are **permanent** for the reconnect
|
|
374
|
-
classifier
|
|
535
|
+
classifier (never auto-reconnect-looped into a misconfigured or MITM'd endpoint) while plain
|
|
375
536
|
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
|
|
537
|
+
NOT guarantee a `clientAuth: 'MUST'` server accepted the client certificate. A rejection
|
|
377
538
|
surfaces moments later as a typed post-connect error classified permanent; ACK correlation
|
|
378
539
|
remains the delivery guarantee. New exported helpers `isTlsVerificationErrorCode(code)` and
|
|
379
540
|
`isTlsProtocolError(err)`. New stats fields: `ClientStats.tls`, `ServerStats.tls`,
|
|
@@ -382,9 +543,9 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
|
|
|
382
543
|
`MLLP_BIND_ALL_INTERFACES`, `isTlsVerificationErrorCode`, `isTlsProtocolError`. See
|
|
383
544
|
`docs-content/tls.md` for the full guide (mTLS table, TLS-1.3 client-cert-rejection note,
|
|
384
545
|
known limitations).
|
|
385
|
-
- **`@cosyte/mllp/ack-from-hl7
|
|
546
|
+
- **`@cosyte/mllp/ack-from-hl7`: real helpers (Phase 7); stub removed.** A thin transport
|
|
386
547
|
adapter over `@cosyte/hl7`'s `buildAck` (hl7 owns ACK content + the HL7 control tables;
|
|
387
|
-
this package frames and correlates
|
|
548
|
+
this package frames and correlates, O-1 boundary). New surface: `buildMllpAck(inbound,
|
|
388
549
|
{ code, error?, encoding?, allowDelimiterBytesInPayload? })` returning a frozen `MllpAck`
|
|
389
550
|
(`frame` ready-to-write MLLP bytes, unframed `payload`, the built `ack` message,
|
|
390
551
|
`requestedCode` vs emitted `code`, verbatim `correlationId`, detected `mode`, content-free
|
|
@@ -392,25 +553,25 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
|
|
|
392
553
|
(original-vs-enhanced from MSH-15/16); lazy peer loading with a typed
|
|
393
554
|
`MllpPeerMissingError` (`MLLP_PEER_MISSING`) when `@cosyte/hl7` is absent; and the
|
|
394
555
|
`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
|
|
556
|
+
a positive ACK (`AA`→`AE`, `CA`→`CE` via the peer's `downgradePositiveAck`, no divergent
|
|
396
557
|
copy of the pair), MSA-2 stays empty, and the result carries the new stable warning code
|
|
397
558
|
**`MLLP_ACK_INBOUND_UNPARSEABLE`** (public API; 12 codes total). A parseable inbound with
|
|
398
559
|
no MSH-10 rides the peer's own downgrade + `ACK_NO_CORRELATION_ID`. MSA-2 echoes the
|
|
399
|
-
inbound MSH-10 whole
|
|
560
|
+
inbound MSH-10 whole: delimiter-bearing vendor-quirk ids (`ID^X`) byte-exact, matching
|
|
400
561
|
this package's own raw-bytes client correlator (escape-bearing ids canonicalize; see the
|
|
401
562
|
docs' known limitations).
|
|
402
563
|
- **Dev/test consumption of the unpublished `@cosyte/hl7` peer** via a vendored packed
|
|
403
564
|
tarball (`vendor/cosyte-hl7-0.0.0.tgz`, devDependency) so the accuracy suite runs against
|
|
404
|
-
the real peer in CI
|
|
565
|
+
the real peer in CI, an interim mechanism until the cross-repo consumption decision
|
|
405
566
|
lands; the runtime peer stays optional and is never bundled (`external` in tsup).
|
|
406
567
|
|
|
407
568
|
### Security
|
|
408
569
|
|
|
409
|
-
- **Dev-dependency advisory remediation (no runtime impact
|
|
570
|
+
- **Dev-dependency advisory remediation (no runtime impact: `@cosyte/mllp`
|
|
410
571
|
ships zero runtime dependencies, so the published artifact is unchanged).**
|
|
411
572
|
Added scoped `pnpm.overrides` pinning two transitive **dev/build-time**
|
|
412
573
|
packages to their patched releases: `esbuild` (`>=0.27.3 <0.28.1` →
|
|
413
|
-
`0.28.1`; GHSA dev-server path-traversal
|
|
574
|
+
`0.28.1`; GHSA dev-server path-traversal, not reachable here: the library
|
|
414
575
|
builds via `tsup`/`vitest` and never runs `esbuild serve`) and the
|
|
415
576
|
`@changesets/parse` copy of `js-yaml` (`>=4.0.0 <4.2.0` → `4.2.0`;
|
|
416
577
|
GHSA-h67p-54hq-rp68 merge-key DoS). The `js-yaml@3.14.2` pulled by
|
|
@@ -423,18 +584,18 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
|
|
|
423
584
|
|
|
424
585
|
### Added
|
|
425
586
|
|
|
426
|
-
- **MLLP client + server
|
|
587
|
+
- **MLLP client + server**: production-grade client and server with framing
|
|
427
588
|
(`VT + payload + FS + CR`), ACK correlation, auto-reconnect with backoff, and backpressure.
|
|
428
589
|
Buffer-first API on every public surface.
|
|
429
|
-
- **Explicit 6-state connection machine
|
|
590
|
+
- **Explicit 6-state connection machine**: `CONNECTING | CONNECTED | DRAINING | RECONNECTING |
|
|
430
591
|
DISCONNECTED | CLOSED`, with `stateChange` events carrying `{ from, to, reason }`.
|
|
431
|
-
- **Framing
|
|
592
|
+
- **Framing**: `FrameReader` with a bounded 16 MB default accumulator (`MLLP_FRAME_TOO_LARGE` on
|
|
432
593
|
overflow); strict encoder, lenient decoder (Postel's Law).
|
|
433
594
|
- **11 stable warning codes** with byte-offset context (`MLLP_MISSING_LEADING_VT`,
|
|
434
595
|
`MLLP_FS_WITHOUT_CR`, `MLLP_FRAME_TOO_LARGE`, `MLLP_ACK_UNMATCHED_CONTROL_ID`, …).
|
|
435
596
|
- **TLS** support; `AbortSignal` on every awaitable and `Symbol.asyncDispose` on every closeable.
|
|
436
|
-
- **In-memory transport** (`@cosyte/mllp/testing`)
|
|
437
|
-
- **`ack-from-hl7` subpath
|
|
597
|
+
- **In-memory transport** (`@cosyte/mllp/testing`): a deterministic, socket-free test double.
|
|
598
|
+
- **`ack-from-hl7` subpath**: placeholder for building ACKs from parsed messages via the optional
|
|
438
599
|
`@cosyte/hl7` peer (helpers not yet implemented; Phase 6).
|
|
439
600
|
- **Property + fuzz test layer** for the framing transport, built on the shared
|
|
440
601
|
`@cosyte/test-utils` conformance kit and `fast-check` (both dev-only). Covers: codec round-trip
|
|
@@ -443,30 +604,30 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
|
|
|
443
604
|
`lenientNeverThrowsProperty`; frozen-event-payload immutability via `immutabilityProperty`; a
|
|
444
605
|
warning-code surface snapshot tripwire via `sortedCodeSet`; and a transport-robustness **fuzz**
|
|
445
606
|
property feeding arbitrary random byte buffers and chunk-splits through `FrameReader` over the
|
|
446
|
-
in-memory transport. Test-only
|
|
607
|
+
in-memory transport. Test-only: no public-surface change.
|
|
447
608
|
|
|
448
609
|
- **Fail-safe ACK semantics & the commit contract (Phase 6, HL7 v2.5.1 §2.9.2).** A positive
|
|
449
610
|
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
|
|
611
|
+
`onMessage` handler the server **awaits the handler (the commit step) then ACKs**: `AA` on resolve,
|
|
451
612
|
a **negative** code on throw/reject (`AE` by default; `AR` via `MllpAckError`), never `AA` before
|
|
452
613
|
commit. `autoAck: 'AA'` without a handler is a documented **transport-accept** (received+framed, not
|
|
453
614
|
application-processed). New public surface: `buildRawAck` (parser-free byte-level ACK builder echoing
|
|
454
615
|
inbound `MSH-10` into `MSA-2`, never throwing on malformed input), the HL7 Table 0008 `AckCode` /
|
|
455
616
|
`NegativeAckCode` unions, `MllpAckError`, `resolveNackCode`, and a PHI-safe `'nack'` event
|
|
456
617
|
(`{ connectionId, ackCode }`) with its `NackEvent` type. No payload content or thrown error text ever
|
|
457
|
-
reaches the wire, logs, or events
|
|
458
|
-
- **Package metadata
|
|
618
|
+
reaches the wire, logs, or events, only routing/control metadata and the static ack code.
|
|
619
|
+
- **Package metadata**: added `homepage` and `bugs` fields to `package.json` for npm completeness.
|
|
459
620
|
|
|
460
621
|
### Changed
|
|
461
622
|
|
|
462
|
-
- **Server bind-safety hardening (Phase 8; BREAKING pre-publish
|
|
623
|
+
- **Server bind-safety hardening (Phase 8; BREAKING pre-publish, free before first release).**
|
|
463
624
|
`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
|
|
625
|
+
`'127.0.0.1'`. Binding a wildcard host now requires `ServerOptions.allowWildcardBind: true`,
|
|
465
626
|
**enforced against the OS-normalized bound address**, not the requested spelling. Literal
|
|
466
627
|
wildcard spellings (`'0.0.0.0'`, `'::'`, `''`, `'::0'`, `'0:0:0:0:0:0:0:0'`,
|
|
467
628
|
`'::ffff:0.0.0.0'`) reject with a typed `MllpConnectionError` **before** binding;
|
|
468
629
|
resolver-only shorthands (`'0'`, `'0.0'`, `'0x0.0.0.0'`, …) are caught by a post-bind check
|
|
469
|
-
on `server.address()
|
|
630
|
+
on `server.address()`. The just-bound server closes immediately and `listen()` rejects,
|
|
470
631
|
leaving no listening state and emitting no `'listening'` event. `listen()` is **single-flight**:
|
|
471
632
|
a call while the server is already listening, or while another `listen()` is in flight, rejects
|
|
472
633
|
with a typed `MllpConnectionError` instead of racing the first call's post-bind checks (a lost
|
|
@@ -490,7 +651,7 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
|
|
|
490
651
|
|
|
491
652
|
### Removed
|
|
492
653
|
|
|
493
|
-
- **`mitata` benchmark dependency and the `bench` script
|
|
654
|
+
- **`mitata` benchmark dependency and the `bench` script**: the script had no benchmark files.
|
|
494
655
|
|
|
495
656
|
### Deprecated
|
|
496
657
|
|
|
@@ -498,29 +659,29 @@ begins its public history at `0.0.x`, per the cosyte version ladder (`0.0.x` unt
|
|
|
498
659
|
|
|
499
660
|
- **Bind errors no longer crash a server with no `'error'` listener (Phase 8 residuals, MLLP-8.1).**
|
|
500
661
|
The constructor-time `net.Server`/`tls.Server` `'error'` forwarder ran before `listen()`'s own
|
|
501
|
-
rejection handler and re-emitted unconditionally
|
|
662
|
+
rejection handler and re-emitted unconditionally: on a server with no `'error'` listener, a plain
|
|
502
663
|
bind error (`EADDRINUSE`, `EACCES`, …) crashed the process (unlistened `EventEmitter` `'error'`
|
|
503
664
|
emissions throw) instead of rejecting the `listen()` promise. The forwarder is now guarded by
|
|
504
665
|
**server state**, and the error contract is documented: with a listener attached, always
|
|
505
666
|
forwarded; with none, a bind-window error rejects the `listen()` promise (the **primary** error
|
|
506
|
-
surface
|
|
667
|
+
surface, caveat: an `'error'` listener that synchronously calls `close()` during the bind window
|
|
507
668
|
changes the rejection to the typed close-during-listen error), a stale error after `close()` is
|
|
508
669
|
dropped, and a runtime error **while serving** (e.g. accept-loop `EMFILE`) deliberately keeps
|
|
509
|
-
Node's fail-loud crash-on-unlistened-`'error'` convention
|
|
670
|
+
Node's fail-loud crash-on-unlistened-`'error'` convention. A silent accept outage is impossible.
|
|
510
671
|
A throwing `'listening'`/`'securityWarning'` subscriber can no longer strand the `listen()`
|
|
511
672
|
promise and wedge the single-flight guard: each emit is contained separately (a throw in one
|
|
512
|
-
subscriber cannot suppress a later emission
|
|
673
|
+
subscriber cannot suppress a later emission, in particular the `MLLP_BIND_ALL_INTERFACES`
|
|
513
674
|
security warning always survives, with the operator-channel `process.emitWarning` fired first so
|
|
514
675
|
no event listener can ever suppress it), the throw is surfaced via the guarded `'error'` tap
|
|
515
676
|
(itself contained against a double throw), and `listen()` still resolves. Also consolidates `listen()`'s five hand-woven settle paths (abort,
|
|
516
677
|
close-during-listen, no-address reject, post-bind wildcard reject, bind error) into one idempotent
|
|
517
|
-
first-caller-wins settle helper
|
|
518
|
-
re-settle a settled promise
|
|
678
|
+
first-caller-wins settle helper (no path can leak a listener, strand the single-flight guard, or
|
|
679
|
+
re-settle a settled promise) and documents three subtleties: the post-bind wildcard reject window
|
|
519
680
|
is accept-safe (the check runs synchronously on `'listening'`, before any connection can be
|
|
520
681
|
delivered); `close({ signal })` with an **already-aborted** signal is a no-op `AbortError`
|
|
521
682
|
rejection that does **not** settle an in-flight `listen()` (which continues and settles on its own
|
|
522
683
|
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
|
|
684
|
+
`'listening'`/`'securityWarning'` handler is deliberately **too late**: the bind wins and
|
|
524
685
|
`listen()` resolves (use `close()` to shut down), so aborted-mid-emit can never strand
|
|
525
686
|
`listening: true` on a closed socket.
|
|
526
687
|
|