Skip to content

feat(utils): ship the HL7v2 simulator as a workspace utility - #18

Open
ipasechnikov wants to merge 4 commits into
mainfrom
feat/hl7v2-simulator
Open

feat(utils): ship the HL7v2 simulator as a workspace utility#18
ipasechnikov wants to merge 4 commits into
mainfrom
feat/hl7v2-simulator

Conversation

@ipasechnikov

@ipasechnikov ipasechnikov commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Vendors hl7v2-simulator into
utils/hl7v2-simulator/ so workspace users can generate synthetic HL7v2 traffic
without an upstream system to point at.

Addresses HealthSamurai/interbox#93.

How you use it

From the repo root, with the dev stack up:

bun run simulator

Installs the simulator and opens its UI on http://localhost:4003, already
pointed at the MLLP port docker-compose.yaml publishes. Click Start all
and messages land in the dashboard.

Why it is not in the root install

It keeps its own package.json and lockfile instead of joining the root
install. A workspace that never simulates traffic shouldn't carry faker and
friends on every bun install. Consequences, both handled:

  • bunfig.toml scopes root bun test to test/. Bare bun test walks the
    whole tree and would otherwise collect the simulator's suite, which cannot
    resolve its imports from the root — verified to fail 5 tests, so CI would
    have broken.
  • CI gets a separate simulator job with working-directory set, running
    bun install --frozen-lockfile + typecheck + tests for that package.

Packaging changes on top of upstream

The vendored tree is otherwise verbatim. The delta:

  • Bind the UI to loopback by default (HOST to override). Bun.serve
    binds 0.0.0.0 when given no hostname, and this server has no
    authentication: /export writes and — with clean: true — deletes files at
    a path taken straight from the request body, and /probe opens TCP
    connections on request. Acceptable on a dev box; not something to expose to
    the network the moment we tell customers to run it. Confirmed with netstat:
    was 0.0.0.0 + [::], now 127.0.0.1 only, with HOST=0.0.0.0 still
    working as the documented escape hatch.
  • Resolve default paths against the package root, not the caller's cwd
    (new src/paths.ts). Starting from the repo root previously failed with
    ENOENT on fixtures/profile.json, and scattered data/sources.json wherever
    you happened to be standing. Paths passed explicitly are still honoured
    as-is.
  • Make the hardcoded INTERBOX MSH-5/6 overridable via RECEIVING_APP /
    RECEIVING_FACILITY, for stands that route on the receiver fields.
  • Drop --hot from bun run ui (kept as ui:dev). The server owns a
    listener and per-source actors that a hot reload leaks — the same shape that
    bit the engine's dev watcher.
  • Move typescript from peer to dev deps so the new CI job has a tsc.
  • Fix the unknown type error to list all four source types (pharmacy was
    missing).

Docs

utils/hl7v2-simulator/README.md is the quick start the issue asked for:
install, run, point at a target, choose source types — plus fault injection,
the generator CLI, and the HTTP API. Every claim was checked against the code.

Two stale claims corrected while writing it, both pre-existing:

  • the root README told users to run bun run bundle, which is not a script
    (the engine bundles at boot)
  • the simulator README documented a --load flag that has no handler

Verification

  • simulator: typecheck clean, 49/49 tests pass, bun install --frozen-lockfile
    works against the committed lockfile
  • root: typecheck clean, 4/4 tests pass, correctly scoped
  • real MLLP round-trip: 50 messages, 50 acked, 0 failed, against a live
    ACK-ing listener, run from the repo root
  • both README curl examples work verbatim
  • generated MSH verified with default and overridden receiver fields
  • fixtures/profile.json (163KB) scanned before publishing to a public repo:
    faker-generated catalogs, anonymized lab systems, public LOINC codes, no PHI,
    no internal hostnames, no credentials

Notes for review

  • Version is intentionally unbumped. Workspace-only releases bump +N
    (currently 1.11.3+0), which can ride this PR or come from the Release
    button after merge — your call. guard-version skips when the version is
    untouched.
  • The vendored package.json asserts "license": "MIT" — upstream has no
    LICENSE file and this repo is MIT, so it inherits sensibly, but worth an
    explicit nod since this is the moment the code becomes MIT in public.
  • Upstream hl7v2-simulator is private and untouched by this PR, so the
    README deliberately does not link to it. Worth deciding separately whether it
    gets archived or redirected here.

🤖 Generated with Claude Code


Update: review pass

A five-axis review of the vendored code (architecture, security, tests) found
issues serious enough that they are fixed here rather than filed. Each was
reproduced before fixing and verified after.

The headline one invalidates a claim made earlier in this PR. Binding to
loopback does not protect against the developer's own browser: Bun's
req.json() ignores Content-Type, so every mutating route was reachable from
any website they visited, via a plain <form enctype="text/plain"> — a CORS
simple request, no preflight, no origin check. A drive-by page could delete a
directory's contents through POST /export. Now guarded by a Host allowlist
(against DNS rebinding, which loopback binding cannot address) plus an
application/json requirement on POST, applied by wrapping the route table so
a new route cannot forget it.

Also fixed: HOST="" silently bound all interfaces (?? where || was
needed); export directories are confined under EXPORT_ROOT; clean deletes
only the .hl7 files the tool wrote — it previously removed unrelated files
and, lacking recursive, threw on the first subdirectory, so it lost data
and failed; HL7 delimiters in a source name let a caller forge MSH-9 and
MSH-10 on traffic aimed at a real engine.

State-machine bugs, all reproduced: a source could be left permanently
unstoppable by Stop-then-Start; a rejected PATCH left memory, disk and pacing
disagreeing; every source emitted identical control/placer/filler/visit IDs,
which defeats the point of a multi-source simulator; sockets and listeners
leaked on several error paths.

The generate-and-maybe-corrupt block existed in five copies that had drifted —
src/ used the seeded RNG, ui/ used Math.random() — so the documented
per-source determinism silently did not hold for anything the UI drove. One
makeGenerator now serves all five.

Tests 49 → 75, chosen for what they catch rather than for coverage: a PRNG
golden vector (its absence meant the constants could change while every test
passed and every generated corpus became irreproducible), prove-it tests for
the two data-integrity bugs, the refused-vs-silent accounting that had no
coverage at all, and MSH injection.

Consciously not done

  • The reliable send path still opens one TCP connection per message.
    Pooling is the right fix but is invasive in the most delicate file here. The
    acute case is defused by lowering the burst cap from 10 000 to 1000, and the
    behaviour is now covered by tests, so the refactor can land on its own.
  • /classic still loads Alpine and Geist from CDNs, so it needs internet.
    The default topology view is self-contained. Vendoring them is a separate,
    larger change.
  • No allowlist on /probe ports. With CSRF closed, reaching it requires
    local access, and restricting it would break pointing the simulator at an
    arbitrary local listener — which is the normal workflow.

Vendors HealthSamurai/hl7v2-simulator into utils/hl7v2-simulator so workspace
users can generate synthetic HL7v2 traffic without an upstream system. Closes
the "make it usable outside the team" half of interbox#93.

It stays a self-contained package with its own package.json and lockfile rather
than joining the root install: a workspace that never simulates traffic
shouldn't carry faker and friends on every `bun install`. `bun run simulator`
from the root installs it and opens the UI, already pointed at the MLLP port
docker-compose publishes.

Packaging changes on top of upstream:

- Bind the UI to loopback by default (HOST to override). Bun.serve otherwise
  binds 0.0.0.0, and this server has no auth — /export writes and, with
  clean:true, deletes files at a path taken from the request body, and /probe
  opens TCP connections on request. Fine on a dev box, not something to hand to
  the network the moment we tell customers to run it.
- Resolve default paths against the package root instead of the caller's cwd,
  so starting from the workspace root works and state doesn't scatter.
- Make the hardcoded INTERBOX MSH-5/6 overridable via RECEIVING_APP /
  RECEIVING_FACILITY, for stands that route on the receiver fields.
- Drop --hot from `bun run ui` (kept as ui:dev); the server holds a listener
  and per-source actors that a reload leaks.
- Move typescript from peer to dev deps so the new CI job can typecheck.

Root wiring: bunfig.toml scopes `bun test` to test/ (bare `bun test` would
otherwise collect the simulator's suite, which can't resolve imports without
that package's own install), and CI gets a separate job for it.

Also corrects two stale README claims found while documenting: `bun run bundle`
is not a script (the engine bundles at boot), and the simulator's `--load` flag
has no handler.
The workspace ingests more than HL7v2 — the engine also has CSV and folder
sources — so a bare `simulator` quietly claims the unqualified name and would
need a breaking rename the day a second one lands. The `hl7v2:` prefix is free
today, groups any future HL7v2 tooling, and echoes the SDK's existing
interbox-hl7v2-* skill vocabulary.

Domain prefix rather than a location one (`utils:`), which would encode a
directory the caller doesn't care about.
Binding to loopback keeps the network out but does nothing about the
developer's own browser, and the README claimed otherwise. Bun's req.json()
ignores Content-Type, so every mutating route was reachable from any website
the developer visited: a plain <form enctype="text/plain"> POSTing a
JSON-shaped field name is a CORS simple request — no preflight, no origin
check, and the attacker never needs to read the response to have caused the
effect. Verified end to end; POST /export with clean:true deleted a target
directory's contents cross-origin.

- Guard every route: Host must be one we expect (this is what stops DNS
  rebinding, which loopback binding cannot), and POST must carry
  application/json. A form cannot send that content-type, and fetch() with it
  preflights, which this server answers with no CORS headers. The check is
  applied by wrapping the route table, so a route added later cannot forget
  it. POST only: PATCH and DELETE are never simple requests, and demanding a
  body content-type on those would break legitimate bodiless calls.
- HOST used `??`, so an empty-but-set HOST (docker run -e HOST, a Kubernetes
  `value: ""`) reached Bun as hostname:"" and bound every interface — while
  Bun still reported "localhost", so the log looked fine. Use `||`, log the
  real bind address, and warn loudly on a wildcard.
- Confine /export and /export/stream under EXPORT_ROOT. Resolving and then
  testing containment is the only check correct on both platforms; rejecting
  ".." textually misses Windows drive-relative and UNC forms.
- Scope `clean` to the .hl7 files we wrote. The blanket rm destroyed unrelated
  files and, lacking `recursive`, threw on the first subdirectory — deleting
  whatever sorted ahead of it and then reporting written: 0.
- Strip HL7 delimiters from source names and bound the length. A name became
  MSH-4 by raw interpolation, so `|` shifted every later field along and let a
  caller forge MSH-9 (what receivers route on) and MSH-10 (what they dedupe
  on) in traffic aimed at a real engine.
- Add ceilings: MAX_SOURCES, MAX_SSE_CLIENTS, MAX_STREAM_FILES, and a 1000-message
  burst cap. Drop SSE frames when a client stops draining rather than queueing
  without limit, and release the subscriber slot on disconnect.
- Validate persisted sources on load, escape `type` in attribute context, and
  escape `<` in JSON embedded in <script>.
- Return a generic 500 instead of the raw error, which carried absolute paths.
…r the gaps

Follow-up to the security pass: the correctness and duplication findings from
the same review.

Bugs, each reproduced before fixing:

- A source could become permanently unstoppable. stop() flips `running`
  synchronously, but the loop can sit in an uncancellable sleep for a whole
  inter-arrival gap — seconds at low rates. A start() in that window began a
  second run; when the first unwound, its tail clobbered the live run's state
  and stop() then early-returned forever. Runs now carry a token and only the
  current one may write the tail. Reachable by clicking Stop then Start.
- A rejected PATCH left memory, disk and the actor's pacing disagreeing:
  update() mutated the live definition field-by-field and validated afterwards.
  Validate everything first, then assign.
- Every source emitted identical control IDs, placer/filler numbers and visit
  numbers, because profileFor specialized only the MRN. A simulator built to
  exercise routing and dedup was handing the receiver one system replayed.
- sendOverMllp abandoned connected sockets when any one failed, and `open`
  removes its own error listener once connected — so a later error on a leaked
  socket was an unhandled 'error' event, which is fatal. allSettled plus an
  explicit teardown.
- The reliable path keyed its response map by message TEXT, so two identical
  bodies shared one entry and the last outcome overwrote the first, losing a
  refusal it had been told about. Key by index.
- writeFrame leaked a `drain` listener per frame on the error path; probePort
  leaked a socket whenever the timeout won its race; Rng.pick/weighted returned
  undefined on an empty distribution, which reached the wire as the literal
  string "undefined".

Generator unification:

  The block "draw a message, roll faultRate, pick a fault, apply it" existed in
  five places and they had drifted — src/ drew fault decisions from the seeded
  Rng, ui/ used Math.random(). So "its own seeded RNG" held for `bun run gen`
  and silently did not for anything the UI drove. One makeGenerator now serves
  all five, so the seed governs content and faults alike, and /export reports
  the seed it used so a batch can be reproduced.

  Also removed genuinely dead code: sendOverMllpStream, streamGenerator, getState.

Tests: 49 -> 75. The additions are the ones that earn their place — a golden
vector for the PRNG (change its constants and every other test still passes
while every generated corpus silently becomes irreproducible), prove-it tests
for the clean-deletes-user-files and rejected-patch bugs, the refused-vs-silent
split that had no coverage at all, MSH injection, and toRow's column contract.
Test hygiene: scratch files go to the OS temp dir and are removed, four
un-awaited rejects assertions now await, and listeners close even when a test
throws.
@ipasechnikov
ipasechnikov force-pushed the feat/hl7v2-simulator branch from 1c69b88 to 6f97556 Compare August 5, 2026 17:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant