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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ on:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run typecheck
# Scoped to test/ by bunfig.toml, so this never reaches into
# utils/hl7v2-simulator/ — that package is checked by the job below.
- run: bun test

# The bundled HL7v2 simulator (utils/hl7v2-simulator) is a self-contained
# package with its own lockfile, so it installs and checks separately. Keeping
# it out of the root install is deliberate: a workspace user who never touches
# the simulator shouldn't pay for faker et al. on every `bun install`.
simulator:
runs-on: ubuntu-latest
defaults:
run:
working-directory: utils/hl7v2-simulator
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
Expand Down
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,27 @@ activation screen there.
Send HL7v2 over MLLP to `localhost:2575` and watch messages flow through to
the FHIR server.

## Send test traffic

If you don't have a real upstream to point at it yet, the bundled HL7v2
simulator generates synthetic traffic (ADT / ORU / SIU / MDM / RDE / RAS) from
several independent sources at once — each with its own pace, MSH identity and
MRN pool, so routing and per-source error handling get exercised rather than a
single uniform stream. With the stack up:

```bash
bun run hl7v2:simulator
```

That installs the simulator and opens its UI on http://localhost:4003, already
pointed at the MLLP port compose publishes. Click **Start all** and watch
messages land in the dashboard.

See [`utils/hl7v2-simulator/`](utils/hl7v2-simulator/) for targets, source types,
fault injection, and the generator CLI. It's a self-contained package with its own
dependencies — deliberately outside the root install, so it costs you nothing if
you never use it.

## Deploy on Kubernetes

For a cluster deployment with the dashboard properly secured — internal ingress,
Expand Down Expand Up @@ -111,5 +132,10 @@ Pipelines load once at engine boot; restart the engine to pick up changes.
```bash
bun install
bun run typecheck
bun run bundle
bun test
```

Tests live in `test/` — `bun test` is scoped to that directory (see
`bunfig.toml`), so a test file placed next to the code it covers will not run.
The bundled simulator under `utils/` is a separate package with its own
dependencies and its own suite; see its README.
14 changes: 14 additions & 0 deletions bunfig.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# ── Bun configuration ────────────────────────────────────────────────────────

[test]
# Confine `bun test` to this workspace's own tests.
#
# Left to itself, `bun test` walks the entire tree and would also collect
# utils/hl7v2-simulator/test/. That package is deliberately NOT part of the root
# install — it carries its own package.json and lockfile so a workspace user who
# never touches the simulator doesn't pay for its dependencies on every
# `bun install` — so those tests can't resolve their imports from here and fail.
#
# Run the simulator's suite in its own directory instead:
# cd utils/hl7v2-simulator && bun install && bun test
root = "test"
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"scripts": {
"typecheck": "tsc --noEmit",
"test": "bun test",
"interbox-cli": "interbox"
"interbox-cli": "interbox",
"hl7v2:simulator": "bun install --cwd utils/hl7v2-simulator && bun run --cwd utils/hl7v2-simulator ui"
},
"dependencies": {
"@health-samurai/interbox": "^1.0.0"
Expand Down
9 changes: 9 additions & 0 deletions utils/hl7v2-simulator/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
node_modules/
*.log
# UI state: persisted source definitions (recreated on next start).
data/
# Generated corpora — `--out-dir`, `--output`, and the UI's /export.
out/
out.csv
out.jsonl
*-out/
262 changes: 262 additions & 0 deletions utils/hl7v2-simulator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
# HL7v2 simulator

Synthetic HL7v2 traffic for testing an Interbox pipeline end to end — ADT, ORU,
SIU, MDM, RDE and RAS, generated from a statistical profile and pushed over MLLP.

It is a **multi-source** simulator: it runs several independent upstream senders
at once, each with its own pace, its own TCP connection, its own MSH identity and
its own MRN pool. Downstream, they are indistinguishable from distinct real
systems — which is what you need to exercise routing, patient matching and
per-source error handling rather than a single well-behaved firehose.

No real data is involved. Content is synthesized from aggregate distributions
plus faker-generated identities; the bundled profile ships with the repo.

> **Not part of the root install.** This is a self-contained package with its own
> `package.json` and lockfile, so a workspace that never uses the simulator
> doesn't carry its dependencies. Install it separately, as shown below.

## Quick start

You need [Bun](https://bun.sh) (`curl -fsSL https://bun.sh/install | bash`).

From the workspace root, with the dev stack already up (`docker compose up`):

```bash
bun run hl7v2:simulator
```

That installs the simulator's dependencies and starts its UI on
**http://localhost:4003**. Open it, click **Start all**, and watch messages
arrive in the Interbox dashboard at http://localhost:3001.

The equivalent long form, if you prefer to work inside this directory:

```bash
cd utils/hl7v2-simulator
bun install
bun run ui
```

The default target is `127.0.0.1:2575` — the MLLP port the workspace's
`docker-compose.yaml` publishes, so out of the box the simulator points at your
local engine with no configuration.

## Point it at a target

The UI's target selector switches between the configured MLLP targets. Three
ship by default:

| Target | Address | What |
| --- | --- | --- |
| **Engine** | `127.0.0.1:2575` | the workspace dev stack (default) |
| **Engine (alt)** | `127.0.0.1:2576` | a second local listener |
| **Mock target** | — | ACKs in-process; no network, no listener needed |

Use **Mock target** to exercise the generator and watch the UI without anything
listening — useful for a first look, or for generating a corpus offline.

Override the list with `TARGETS`, a comma-separated `label:host:port`:

```bash
TARGETS="Staging:hl7.internal:2575,Local:127.0.0.1:2575" bun run ui
```

The first entry becomes the default selection. Individual sources can override
just the port (see the inspector panel, or `targetPort` below) while sharing the
selected host — handy when one pipeline listens on its own port.

Other environment knobs:

| Variable | Default | What |
| --- | --- | --- |
| `PORT` | `4003` | port the simulator UI listens on |
| `HOST` | `127.0.0.1` | interface the UI binds to — see the warning below |
| `TARGETS` | the three above | MLLP targets, `label:host:port,…` |
| `PROFILE_PATH` | this package's `fixtures/profile.json` | generator profile |
| `PROFILE_NAME` | `default` | profile label shown in the UI |
| `SOURCES_PATH` | this package's `data/sources.json` | where source definitions persist |
| `EXPORT_ROOT` | this package's `batch-out/` | **every** export directory must resolve inside this |
| `EXPORT_DIR` | this package's `batch-out/` | directory prefilled in the export form |
| `MAX_STREAM_RATE` | `1000` | per-source msg/s ceiling |
| `MAX_SOURCES` | `64` | how many sources may exist at once |
| `MAX_SSE_CLIENTS` | `32` | concurrent `/events` subscribers |
| `MAX_STREAM_FILES` | `100000` | files the folder-stream writes before stopping itself |
| `RECEIVING_APP` / `RECEIVING_FACILITY` | `INTERBOX` | MSH-5 / MSH-6 on generated messages |

Path defaults resolve inside this package, so the simulator behaves the same
whether you start it from here or from the workspace root. A path you pass
explicitly is used as given — a relative one resolves against your shell's
working directory, as usual. The one exception is the export directory, which is
always resolved inside `EXPORT_ROOT` (see below).

## Security

The simulator has **no authentication**. It is a developer tool, and the design
assumes only you can reach it. Three things enforce that:

- **It binds loopback** (`127.0.0.1`) unless you set `HOST`.
- **It requires `application/json` on POST.** Loopback binding alone is not
enough: a form on any website you visit can POST to `localhost` as a CORS
simple request — no preflight, no origin check — and the attacker does not
need to read the response to have caused the effect. A form cannot send a JSON
content-type, and `fetch()` with one preflights, which this server declines.
- **It checks the `Host` header.** That is what stops DNS rebinding, where an
attacker's domain re-resolves to `127.0.0.1` and thereby becomes same-origin —
able to read responses, which the content-type rule cannot prevent.

Export directories are confined under `EXPORT_ROOT`, and `clean` removes only the
`.hl7` files the simulator itself wrote, so a mistargeted export cannot delete
your data.

> **Setting `HOST=0.0.0.0` gives up the first and third of those**, and hands
> traffic generation and file writes under `EXPORT_ROOT` to anyone who can reach
> the port. Do it only on a network you control, never on a shared or
> internet-facing host. The server prints a warning at startup when you do.

## Choose source types

A source is a persisted definition driven by its own actor:

```ts
SourceDef { id, name, type, rate, faultRate, targetPort?, msgTypes? }
```

`type` picks the sending application (MSH-3) and the message mix:

| Type | MSH-3 | Message mix |
| --- | --- | --- |
| `lab` | `LAB_IF` | ORU^R01 75% · ORM^O01 10% · ADT^A08 15% |
| `clinic` | `CLINIC_EHR` | ADT^A08 55% · SIU^S12 45% |
| `hospital` | `HOSP_ADT` | ADT^A01 30% · ADT^A03 20% · ADT^A08 20% · ORU^R01 20% · MDM^T02 10% |
| `pharmacy` | `PHARM_SYS` | RDE^O11 50% · RAS^O17 35% · ADT^A08 15% |

Add one in the UI with **Add source** — name it, pick a type, set `rate`
(messages per second) and `faultRate` (0–1, the fraction deliberately corrupted).
Or over HTTP:

```bash
curl -X POST localhost:4003/sources \
-H 'content-type: application/json' \
-d '{"name":"Sunrise Lab","type":"lab","rate":5,"faultRate":0.05}'
```

To override the preset mix, pass `msgTypes` — an explicit, equally weighted set
drawn from `ORU^R01`, `ORM^O01`, `ADT^A01`, `ADT^A03`, `ADT^A08`, `SIU^S12`,
`MDM^T02`, `MDM^T07`, `MDM^T11`, `RDE^O01`, `RDE^O11`, `RAS^O17`:

```bash
curl -X POST localhost:4003/sources \
-H 'content-type: application/json' \
-d '{"name":"ADT Only","type":"hospital","rate":2,"faultRate":0,
"msgTypes":["ADT^A01","ADT^A03"]}'
```

**Identity.** Each source stamps its own MSH-3 (sending application) and MSH-4
(sending facility, from the name) and draws MRNs from its own pool under its own
assigning authority.

**Isolation.** Every source gets its own Poisson loop, its own TCP socket and its
own seeded RNG, so streams interleave on the wire and one source failing does not
disturb the others. Fault injection is per-source via `faultRate`.

**Distinct identifiers.** Control IDs, placer/filler numbers and visit numbers
all carry the source's own prefix, as MRNs do — so a receiver's deduplication
sees genuinely separate systems rather than one system replayed.

## Faults

`faultRate` is the fraction of messages deliberately broken before sending —
truncated segments, bad field counts, unparseable timestamps and the like. The
simulator classifies each locally, so its summary previews how the engine will
bucket them (`parse_error` / `map_error` / `data_quality`, or benign-but-valid).
Set it to `0` for a clean stream, or crank it to see the dashboard's error views
populate.

## Reproducibility

The seed governs everything: message content, identities, **and** which messages
get corrupted. Same seed, same corpus, down to the faults.

```bash
bun run gen 1000 0.05 42 --out-dir ./corpus # rerun with 42 to get it back
```

`POST /export` reports the `seed` it used; pass that seed back to regenerate the
same batch. Live streaming deliberately seeds itself fresh per run, so successive
demos differ.

## The two views

| Route | View |
| --- | --- |
| `/` | **Topology** — sources around the engine hub, live traffic as moving dots, per-source inspector |
| `/classic` | Single stream — EKG-style waveform, one generator, one target |

> `/classic` loads Alpine.js and Geist from public CDNs, so it needs internet.
> The default topology view at `/` is fully self-contained and works offline.

## CLI

The UI is optional — the generator and sender are usable on their own.

```bash
# Generate and inspect (count, faultRate, seed)
bun run gen 1000 0.05 42

# Write a corpus: out.jsonl / out.csv, or one .hl7 file per message
bun run gen 1000 0.05 42 --output jsonl
bun run gen 500 0 42 --out-dir ./corpus --clean # --clean removes only .hl7 files
bun run gen 200 0 42 --types ADT^A01,ORU^R01 # force an even mix

# Send over MLLP to a running engine
bun run send batch --count 500 --months 3 # fixed count, MSH-7 spread over 3 months
bun run send stream --rate 20 --poisson # paced live stream until Ctrl-C
bun run send <mode> --help # per-mode flags

bun run selftest # generate and validate against the real parser
bun test
bun run typecheck
```

`--out-dir` writes CR-separated `.hl7` files with no MLLP framing — the shape a
folder source ingests, for testing that path without a socket.

## HTTP API

| Method | Path | What |
| --- | --- | --- |
| GET · POST | `/sources` | list · create |
| PATCH · DELETE | `/sources/:id` | update · remove |
| POST | `/sources/:id/send` | one-off send from this source |
| POST | `/sources/:id/stream` | start / stop this source |
| POST | `/sources/stream-all` | start / stop every source |
| GET | `/events` | SSE — per-send ticks and counters |
| GET | `/msg-types` | message types available for hand-picking |
| GET | `/probe?port=N` | TCP reachability check for a target port |
| GET · POST | `/export` | folder-streamer state · write `.hl7` files to a directory |
| POST | `/export/stream` | start / stop trickling `.hl7` files into a directory |
| GET · POST | `/targets` | list configured targets · switch the active one |
| POST · PATCH | `/send`, `/stream/start`, `/stream/stop`, `/stream` | single-stream API behind `/classic` |
| GET | `/health` | liveness + active target |

## What's where

| Path | What |
| --- | --- |
| `ui/server.ts` | Bun.serve — the routes above |
| `ui/topology.ts` | Topology view — hub, source nodes, flow curves, inspector |
| `ui/sources.ts` | `SourceRegistry` — definitions, presets, persistence |
| `ui/actor.ts` | `SourceActor` — per-source loop, socket, counters |
| `ui/bus.ts` | SSE pub/sub |
| `ui/page.ts` | Classic single-stream page |
| `ui/generator.ts` | Thin wrapper around `src/gen/*` + `src/send/mllp.ts` |
| `ui/stream.ts` | Server-side Poisson loop for the classic view |
| `src/cli.ts` | CLI generator (`bun run gen`) |
| `src/send-cli.ts` | MLLP sender CLI, `batch` / `stream` (`bun run send`) |
| `src/gen/` | Message synthesis (profile-driven, no real data) |
| `src/gen/stream.ts` | `makeGenerator` — the one seeded generate-and-maybe-corrupt used by every caller |
| `src/send/mllp.ts` | MLLP transport — fire-and-forget, reliable (ACK-aware), live stream |
| `src/paths.ts` | Package-relative path defaults, and the export-directory confinement |
| `fixtures/` | `profile.json` (the shipped generator profile) and `profile.example.json` (a smaller one used by the tests) |
| `test/` | Unit tests, incl. `sources.test.ts` for the registry |
32 changes: 32 additions & 0 deletions utils/hl7v2-simulator/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading